Skip to content

buf.validate

buf.validate (protovalidate) lets you annotate proto fields with validation rules. protoc-gen-pydantic translates these rules into native Pydantic constructs — no plugin option needed.

Setup

Add the BSR dependency to buf.yaml:

# buf.yaml
version: v2
modules:
  - path: proto
deps:
  - buf.build/bufbuild/protovalidate

Lock the dependency:

buf dep update

Import the validate file in your proto:

import "buf/validate/validate.proto";

Constraint translations

buf.validate rule Generated Pydantic construct
(buf.validate.field).cel Annotated[T, AfterValidator(_make_cel_validator(lambda v: ..., "msg"))] — see CEL expressions
(buf.validate.field).cel_expression same as .cel — shorthand where id = expression, message = "" — see cel_expression shorthand
option (buf.validate.message).cel @model_validator(mode="after") method — see CEL expressions
option (buf.validate.message).cel_expression same as .cel — shorthand where id = expression, message = "" — see cel_expression shorthand
Numeric gt Field(gt=...)
Numeric gte Field(ge=...)
Numeric lt Field(lt=...)
Numeric lte Field(le=...)
string.min_len Field(min_length=...)
string.max_len Field(max_length=...)
string.len Field(min_length=N, max_length=N)
string.pattern Field(pattern=...)
string.contains Field(pattern=<substring>)
string.prefix Field(pattern=^prefix.*)
string.suffix Field(pattern=.*suffix$)
string.prefix + string.suffix Field(pattern=^prefix.*suffix$)
repeated.min_items Field(min_length=...)
repeated.max_items Field(max_length=...)
repeated.items per-element Annotated[T, ...] wrapping
map.min_pairs Field(min_length=...)
map.max_pairs Field(max_length=...)
map.keys per-key dict[Annotated[K, ...], V] wrapping
map.values per-value dict[K, Annotated[V, ...]] wrapping
bytes.min_len Field(min_length=...)
bytes.max_len Field(max_length=...)
bytes.len Field(min_length=N, max_length=N)
field.example Field(examples=[...])
string.const / int.const / bool.const Literal[value] type + matching default
float.const / double.const Annotated[float, AfterValidator(_make_const_validator(value))]
float.finite / double.finite Annotated[float, AfterValidator(_require_finite)]
string.in / int.in / etc. Annotated[T, AfterValidator(_make_in_validator(frozenset({...})))]
string.not_in / etc. Annotated[T, AfterValidator(_make_not_in_validator(frozenset({...})))]
repeated.unique Annotated[list[T], AfterValidator(_require_unique)]
string.email Annotated[str, AfterValidator(_validate_email)]
string.uri Annotated[str, AfterValidator(_validate_uri)]
string.ip Annotated[str, AfterValidator(_validate_ip)]
string.ipv4 Annotated[str, AfterValidator(_validate_ipv4)]
string.ipv6 Annotated[str, AfterValidator(_validate_ipv6)]
string.uuid Annotated[str, AfterValidator(_validate_uuid)]
string.hostname Annotated[str, AfterValidator(_validate_hostname)]
string.uri_ref Annotated[str, AfterValidator(_validate_uri_ref)]
string.address Annotated[str, AfterValidator(_validate_address)]
string.tuuid Annotated[str, AfterValidator(_validate_tuuid)]
string.ulid Annotated[str, AfterValidator(_validate_ulid)]
string.ip_with_prefixlen Annotated[str, AfterValidator(_validate_ip_with_prefixlen)]
string.ipv4_with_prefixlen Annotated[str, AfterValidator(_validate_ipv4_with_prefixlen)]
string.ipv6_with_prefixlen Annotated[str, AfterValidator(_validate_ipv6_with_prefixlen)]
string.ip_prefix Annotated[str, AfterValidator(_validate_ip_prefix)]
string.ipv4_prefix Annotated[str, AfterValidator(_validate_ipv4_prefix)]
string.ipv6_prefix Annotated[str, AfterValidator(_validate_ipv6_prefix)]
string.host_and_port Annotated[str, AfterValidator(_validate_host_and_port)]
string.well_known_regex = KNOWN_REGEX_HTTP_HEADER_NAME Annotated[str, AfterValidator(_validate_http_header_name)]
string.well_known_regex = KNOWN_REGEX_HTTP_HEADER_VALUE Annotated[str, AfterValidator(_validate_http_header_value)]
string.not_contains Annotated[str, AfterValidator(_make_not_contains_validator(...))]
string.min_bytes Annotated[str, AfterValidator(_make_min_bytes_validator(N))]
string.max_bytes Annotated[str, AfterValidator(_make_max_bytes_validator(N))]
string.len_bytes Annotated[str, AfterValidator(_make_len_bytes_validator(N))]
bytes.uuid Annotated[bytes, AfterValidator(_validate_bytes_uuid)]
bytes.ip Annotated[bytes, AfterValidator(_validate_bytes_ip)]
bytes.ipv4 Annotated[bytes, AfterValidator(_validate_bytes_ipv4)]
bytes.ipv6 Annotated[bytes, AfterValidator(_validate_bytes_ipv6)]

Examples

Numeric bounds

message ValidatedScalars {
  // Age must be between 0 and 150 exclusive of 0.
  int32 age = 1 [(buf.validate.field).int32.gt = 0, (buf.validate.field).int32.lte = 150];
  // Score must be in [0.0, 100.0].
  double score = 2 [(buf.validate.field).double.gte = 0.0, (buf.validate.field).double.lte = 100.0];
  // Priority must be positive.
  int64 priority = 3 [(buf.validate.field).int64.gt = 0];
  // Ratio must be non-negative and less than 1.
  float ratio = 4 [(buf.validate.field).float.gte = 0.0, (buf.validate.field).float.lt = 1.0];
  // Rank must be in [1, 10].
  uint32 rank = 5 [(buf.validate.field).uint32.gte = 1, (buf.validate.field).uint32.lte = 10];
  // Count must be non-zero (covers uint64 / fixed64 literal formatting).
  optional uint64 count = 6 [(buf.validate.field).uint64.gt = 0];
  // Offset must be non-negative (covers sint32 / sfixed32 literal formatting).
  optional sint32 offset = 7 [(buf.validate.field).sint32.gte = 0];
}
class ValidatedScalars(_ProtoModel):
    """
    ValidatedScalars exercises numeric bound constraints.
    """

    # Age must be between 0 and 150 exclusive of 0.
    age: int = _Field(
        description="Age must be between 0 and 150 exclusive of 0.",
        gt=0,
        le=150,
    )
    # Score must be in [0.0, 100.0].
    score: float = _Field(
        default=0.0,
        description="Score must be in [0.0, 100.0].",
        ge=0.0,
        le=100.0,
    )
    # Priority must be positive.
    priority: ProtoInt64 = _Field(
        description="Priority must be positive.",
        gt=0,
    )
    # Ratio must be non-negative and less than 1.
    ratio: float = _Field(
        default=0.0,
        description="Ratio must be non-negative and less than 1.",
        ge=0.0,
        lt=1.0,
    )
    # Rank must be in [1, 10].
    rank: int = _Field(
        description="Rank must be in [1, 10].",
        ge=1,
        le=10,
    )
    # Count must be non-zero (covers uint64 / fixed64 literal formatting).
    count: ProtoUInt64 | None = _Field(
        default=None,
        description="Count must be non-zero (covers uint64 / fixed64 literal formatting).",
        gt=0,
    )
    # Offset must be non-negative (covers sint32 / sfixed32 literal formatting).
    offset: int | None = _Field(
        default=None,
        description="Offset must be non-negative (covers sint32 / sfixed32 literal formatting).",
        ge=0,
    )

String constraints

message ValidatedStrings {
  // Name must be between 1 and 100 characters.
  string name = 1 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 100];
  // Code must match uppercase letters only.
  string code = 2 [(buf.validate.field).string.pattern = "^[A-Z]+$"];
  // Bio has only a max length.
  string bio = 3 [(buf.validate.field).string.max_len = 500];
  // Tag has only a min length.
  string tag = 4 [(buf.validate.field).string.min_len = 2];
}
class ValidatedStrings(_ProtoModel):
    """
    ValidatedStrings exercises string length and pattern constraints.
    """

    # Name must be between 1 and 100 characters.
    name: str = _Field(
        description="Name must be between 1 and 100 characters.",
        min_length=1,
        max_length=100,
    )
    # Code must match uppercase letters only.
    code: str = _Field(
        description="Code must match uppercase letters only.",
        pattern="^[A-Z]+$",
    )
    # Bio has only a max length.
    bio: str = _Field(
        default="",
        description="Bio has only a max length.",
        max_length=500,
    )
    # Tag has only a min length.
    tag: str = _Field(
        description="Tag has only a min length.",
        min_length=2,
    )

String byte-length constraints

string.min_bytes, string.max_bytes, and string.len_bytes constrain the UTF-8 byte count of a string — semantically different from min_len/max_len which count Unicode codepoints. A string like "日" is 1 codepoint but 3 UTF-8 bytes.

message ValidatedStringBytes {
  // Payload must be at least 1 UTF-8 byte (ConstrainedRequired: min_bytes > 0).
  string payload = 1 [(buf.validate.field).string.min_bytes = 1];
  // Token must be exactly 32 UTF-8 bytes (ConstrainedRequired).
  string token = 2 [(buf.validate.field).string.len_bytes = 32];
  // Label has only a max_bytes limit (NOT ConstrainedRequired: "" is 0 bytes ≤ 255).
  string label = 3 [(buf.validate.field).string.max_bytes = 255];
  // Tag exercises min_bytes + max_bytes together (ConstrainedRequired: min_bytes > 0).
  string tag = 4 [
    (buf.validate.field).string.min_bytes = 2,
    (buf.validate.field).string.max_bytes = 64
  ];
}
class ValidatedStringBytes(_ProtoModel):
    """
    ValidatedStringBytes exercises string.min_bytes/max_bytes/len_bytes constraints.
    """

    # Payload must be at least 1 UTF-8 byte (ConstrainedRequired: min_bytes > 0).
    payload: _Annotated[str, _AfterValidator(_make_min_bytes_validator(1))] = _Field(
        description="Payload must be at least 1 UTF-8 byte (ConstrainedRequired: min_bytes > 0).",
    )
    # Token must be exactly 32 UTF-8 bytes (ConstrainedRequired).
    token: _Annotated[str, _AfterValidator(_make_len_bytes_validator(32))] = _Field(
        description="Token must be exactly 32 UTF-8 bytes (ConstrainedRequired).",
    )
    # Label has only a max_bytes limit (NOT ConstrainedRequired: "" is 0 bytes ≤ 255).
    label: _Annotated[str, _AfterValidator(_make_max_bytes_validator(255))] = _Field(
        default="",
        description='Label has only a max_bytes limit (NOT ConstrainedRequired: "" is 0 bytes ≤ 255).',
    )
    # Tag exercises min_bytes + max_bytes together (ConstrainedRequired: min_bytes > 0).
    tag: _Annotated[
        str,
        _AfterValidator(_make_min_bytes_validator(2)),
        _AfterValidator(_make_max_bytes_validator(64)),
    ] = _Field(
        description="Tag exercises min_bytes + max_bytes together (ConstrainedRequired: min_bytes > 0).",
    )

Note: min_bytes > 0 and len_bytes > 0 trigger zero-value validationpayload, token, and tag above are required fields. max_bytes-only fields keep their zero default because "" has 0 bytes.

Format validators

Format validators are translated to AfterValidator wrappers. The validators are generated into _proto_types.py alongside the model files.

message ValidatedFormats {
  // Email must be a valid email address.
  string email = 1 [(buf.validate.field).string.email = true];
  // Website must be a valid URI.
  string website = 2 [(buf.validate.field).string.uri = true];
  // Address must be a valid IP address.
  string address = 3 [(buf.validate.field).string.ip = true];
  // Ratio must be finite (not inf or NaN).
  float ratio = 4 [(buf.validate.field).float.finite = true];
  // Token must be a valid UUID.
  string token = 5 [(buf.validate.field).string.uuid = true];
  // Host must be a valid IPv4 address.
  string host_v4 = 6 [(buf.validate.field).string.ipv4 = true];
  // Host must be a valid IPv6 address.
  string host_v6 = 7 [(buf.validate.field).string.ipv6 = true];
}
class ValidatedFormats(_ProtoModel):
    """
    ValidatedFormats exercises format and semantic validators: email, URI,
    IP address (v4/v6), UUID, and float finite.
    """

    # Email must be a valid email address.
    email: _Annotated[str, _AfterValidator(_validate_email)] = _Field(
        description="Email must be a valid email address.",
    )
    # Website must be a valid URI.
    website: _Annotated[str, _AfterValidator(_validate_uri)] = _Field(
        description="Website must be a valid URI.",
    )
    # Address must be a valid IP address.
    address: _Annotated[str, _AfterValidator(_validate_ip)] = _Field(
        description="Address must be a valid IP address.",
    )
    # Ratio must be finite (not inf or NaN).
    ratio: _Annotated[float, _AfterValidator(_require_finite)] = _Field(
        default=0.0,
        description="Ratio must be finite (not inf or NaN).",
    )
    # Token must be a valid UUID.
    token: _Annotated[str, _AfterValidator(_validate_uuid)] = _Field(
        description="Token must be a valid UUID.",
    )
    # Host must be a valid IPv4 address.
    host_v4: _Annotated[str, _AfterValidator(_validate_ipv4)] = _Field(
        description="Host must be a valid IPv4 address.",
    )
    # Host must be a valid IPv6 address.
    host_v6: _Annotated[str, _AfterValidator(_validate_ipv6)] = _Field(
        description="Host must be a valid IPv6 address.",
    )

Note: Non-optional proto3 scalar fields with format validators become required in the generated model — the empty string (proto3 zero value) would fail format validation. To allow empty strings, mark the field optional in proto3 or annotate it with ignore = IGNORE_IF_ZERO_VALUE (see Zero-value validation).

The string.email validator requires the email-validator package (pip install email-validator or add to your project dependencies).

Finite float / double

float.finite = true and double.finite = true reject inf and NaN values:

message ValidatedFinite {
  // Ratio must be finite (not inf or NaN).
  float ratio = 1 [(buf.validate.field).float.finite = true];
  // Value must be finite (not inf or NaN).
  double value = 2 [(buf.validate.field).double.finite = true];
}
class ValidatedFinite(_ProtoModel):
    """
    ValidatedFinite exercises float.finite and double.finite constraints.
    """

    # Ratio must be finite (not inf or NaN).
    ratio: _Annotated[float, _AfterValidator(_require_finite)] = _Field(
        default=0.0,
        description="Ratio must be finite (not inf or NaN).",
    )
    # Value must be finite (not inf or NaN).
    value: _Annotated[float, _AfterValidator(_require_finite)] = _Field(
        default=0.0,
        description="Value must be finite (not inf or NaN).",
    )

Set membership (in / not_in)

message ValidatedIn {
  string status   = 1 [(buf.validate.field) = {string: {in: ["active", "inactive"]}}];
  string code     = 2 [(buf.validate.field) = {string: {not_in: ["deleted", "archived"]}}];
  int32  priority = 3 [(buf.validate.field) = {int32: {in: [1, 2, 3]}}];
}
class ValidatedIn(_ProtoModel):
    """
    ValidatedIn exercises in and not_in constraints translated to AfterValidator.
    """

    status: _Annotated[
        str, _AfterValidator(_make_in_validator(frozenset({"active", "inactive"})))
    ]
    code: _Annotated[
        str, _AfterValidator(_make_not_in_validator(frozenset({"deleted", "archived"})))
    ] = _Field(
        default="",
    )
    priority: _Annotated[int, _AfterValidator(_make_in_validator(frozenset({1, 2, 3})))]
    # Limit covers uint32.in (exercises the uint path in formatScalarLiteral).
    limit: _Annotated[
        int, _AfterValidator(_make_in_validator(frozenset({10, 50, 100})))
    ] = _Field(
        description="Limit covers uint32.in (exercises the uint path in formatScalarLiteral).",
    )

Unique elements in repeated fields

message ValidatedUnique {
  repeated string tags   = 1 [(buf.validate.field).repeated.unique = true];
  repeated int32  scores = 2 [(buf.validate.field).repeated.unique = true];
}
class ValidatedUnique(_ProtoModel):
    """
    ValidatedUnique exercises repeated.unique translated to AfterValidator.
    """

    tags: _Annotated[list[str], _AfterValidator(_require_unique)] = _Field(
        default_factory=list,
    )
    scores: _Annotated[list[int], _AfterValidator(_require_unique)] = _Field(
        default_factory=list,
    )

Per-entry constraints on map fields (map.keys / map.values)

map.keys and map.values apply a nested FieldConstraints to every key or value in the map. The generated type wraps the key and/or value with Annotated[..., ...] — any constraint that works on a scalar field also works here:

message ValidatedMapConstraints {
  // Keys must be 1–63 chars; values must be non-empty.
  map<string, string> labels = 1 [
    (buf.validate.field).map.keys   = { string: { min_len: 1, max_len: 63 } },
    (buf.validate.field).map.values = { string: { min_len: 1 } }
  ];
  // Values must be positive.
  map<string, int32> counters = 2 [
    (buf.validate.field).map.values = { int32: { gt: 0 } }
  ];
}
class ValidatedMapConstraints(_ProtoModel):
    """
    ValidatedMapConstraints exercises map.keys and map.values per-entry constraints.
    """

    # Keys must be 1–63 chars; values must be non-empty.
    labels: dict[
        _Annotated[str, _Field(min_length=1, max_length=63)],
        _Annotated[str, _Field(min_length=1)],
    ] = _Field(
        default_factory=dict,
        description="Keys must be 1–63 chars; values must be non-empty.",
    )
    # Values must be positive.
    counters: dict[str, _Annotated[int, _Field(gt=0)]] = _Field(
        default_factory=dict,
        description="Values must be positive.",
    )
    # Keys must be a valid email; values must match a pattern.
    rules: dict[
        _Annotated[str, _AfterValidator(_validate_email)],
        _Annotated[str, _Field(pattern="^[a-z]+$")],
    ] = _Field(
        default_factory=dict,
        description="Keys must be a valid email; values must match a pattern.",
    )
    # Must have at least 1 entry; keys must be non-empty (tests min_pairs + keys together).
    tagged: dict[_Annotated[str, _Field(min_length=1)], str] = _Field(
        default_factory=dict,
        description="Must have at least 1 entry; keys must be non-empty (tests min_pairs + keys together).",
        min_length=1,
    )
    # Integer keys must be positive (exercises non-string key constraints).
    scores: dict[
        _Annotated[int, _Field(gt=0)],
        _Annotated[str, _AfterValidator(_make_not_in_validator(frozenset({""})))],
    ] = _Field(
        default_factory=dict,
        description="Integer keys must be positive (exercises non-string key constraints).",
    )

The constraint types supported inside map.keys and map.values are the same as for any scalar field: numeric bounds, string length / pattern / format validators, in / not_in, etc. Format validators on keys (e.g. email: true) and pattern validators on values work just as they do on top-level fields.

Const (fixed values)

string.const, integer const (int32, uint32, etc.), and bool.const translate to Literal[value] type with a matching default — the field is essentially fixed at that value. float.const and double.const use AfterValidator(_make_const_validator(value)) since Literal[float] is invalid per PEP 586:

message ValidatedConst {
  string tag      = 1 [(buf.validate.field).string.const  = "fixed"];
  int32  count    = 2 [(buf.validate.field).int32.const   = 42];
  bool   active   = 3 [(buf.validate.field).bool.const    = true];
  double score    = 4 [(buf.validate.field).double.const  = 3.14];
  uint32 code     = 5 [(buf.validate.field).uint32.const  = 100];
  bool   inactive = 6 [(buf.validate.field).bool.const    = false];
}
class ValidatedConst(_ProtoModel):
    """
    ValidatedConst exercises the const constraint translated to Literal[...].
    """

    tag: _Literal["fixed"] = _Field(default="fixed")
    count: _Literal[42] = _Field(default=42)
    active: _Literal[True] = _Field(default=True)
    score: _Annotated[float, _AfterValidator(_make_const_validator(3.14))] = _Field(
        default=3.14,
    )
    code: _Literal[100] = _Field(default=100)
    inactive: _Literal[False] = _Field(default=False)

Required (proto3 optional + required)

required = true behaves differently depending on the field type:

  • proto3 optional scalar: strips | None from the type — the field becomes required at the Pydantic level (no default).
  • proto3 optional scalar + additional constraint: same stripping, constraint also applied.
  • Message-typed optional: cannot be translated — emits a # buf.validate: required (not translated) comment.
  • Plain proto3 scalar: cannot be translated (already has a zero default) — emits a dropped comment.
message ValidatedRequired {
  // required on proto3 optional scalar: | None stripped, field becomes required.
  optional string required_name = 1 [(buf.validate.field).required = true];
  // required on proto3 optional scalar with an additional constraint.
  optional int32 required_score = 2 [
    (buf.validate.field).required = true,
    (buf.validate.field).int32.gt = 0
  ];
  // required on message-typed optional: not translated, emits dropped comment.
  optional Detail required_detail = 3 [(buf.validate.field).required = true];
  // required on plain proto3 scalar: not translated, emits dropped comment.
  string plain_name = 4 [(buf.validate.field).required = true];

  message Detail {
    string value = 1;
  }
}
class ValidatedRequired(_ProtoModel):
    """
    ValidatedRequired exercises required = true on proto3 optional scalar fields
    (where it strips | None) vs. message-typed and plain scalar fields (dropped).
    """

    class Detail(_ProtoModel):
        """
        Detail is a nested message used to test message-typed required handling.
        """

        value: str = _Field(default="")

    # required on proto3 optional scalar: | None stripped, field becomes required.
    required_name: str = _Field(
        default=...,
        description="required on proto3 optional scalar: | None stripped, field becomes required.",
    )
    # required on proto3 optional scalar with an additional constraint.
    required_score: int = _Field(
        default=...,
        description="required on proto3 optional scalar with an additional constraint.",
        gt=0,
    )
    # required on message-typed optional: not translated, emits dropped comment.
    required_detail: "ValidatedRequired.Detail | None" = _Field(
        default=None,
        description="required on message-typed optional: not translated, emits dropped comment.",
        # buf.validate: required (not translated)
    )
    # required on plain proto3 scalar: not translated, emits dropped comment.
    plain_name: str = _Field(
        default="",
        description="required on plain proto3 scalar: not translated, emits dropped comment.",
        # buf.validate: required (not translated)
    )

The _proto_types.py file

Format validators (_validate_email, _validate_uri, etc.), set validators (_make_in_validator, _make_not_in_validator, _require_unique), and other helpers (_require_finite, _make_const_validator) live in a generated _proto_types.py file that is placed alongside the model files.

This file is conditional — only helpers actually used by the proto files in that directory are included. Unused imports (e.g. ipaddress, AnyUrl) are omitted.

gen/
└── api/v1/
    ├── user_pydantic.py
    ├── order_pydantic.py
    └── _proto_types.py        # generated helpers (only what's needed)

Extended format validators

In addition to the core six (email, uri, ip, ipv4, ipv6, uuid), the following string format constraints are also translated to AfterValidator wrappers:

message ValidatedFormatsExtended {
  // Hostname must be a valid DNS hostname.
  string hostname = 1 [(buf.validate.field).string.hostname = true];
  // UriRef must be a valid URI reference (absolute or relative).
  string uri_ref = 2 [(buf.validate.field).string.uri_ref = true];
  // Addr must be a valid IP address or hostname.
  string addr = 3 [(buf.validate.field).string.address = true];
  // Tuuid must be a trimmed UUID (32 hex chars, no dashes).
  string tuuid = 4 [(buf.validate.field).string.tuuid = true];
  // Ulid must be a valid ULID.
  string ulid = 5 [(buf.validate.field).string.ulid = true];
  // Cidr must be a valid IP address with prefix length (host address).
  string cidr = 6 [(buf.validate.field).string.ip_with_prefixlen = true];
  // CidrV4 must be a valid IPv4 address with prefix length.
  string cidr_v4 = 7 [(buf.validate.field).string.ipv4_with_prefixlen = true];
  // CidrV6 must be a valid IPv6 address with prefix length.
  string cidr_v6 = 8 [(buf.validate.field).string.ipv6_with_prefixlen = true];
  // IpNet must be a valid IP network (host bits must be zero).
  string ip_net = 9 [(buf.validate.field).string.ip_prefix = true];
  // Ipv4Net must be a valid IPv4 network (host bits must be zero).
  string ipv4_net = 10 [(buf.validate.field).string.ipv4_prefix = true];
  // Ipv6Net must be a valid IPv6 network (host bits must be zero).
  string ipv6_net = 11 [(buf.validate.field).string.ipv6_prefix = true];
  // Endpoint must be a valid host:port pair.
  string endpoint = 12 [(buf.validate.field).string.host_and_port = true];
}
class ValidatedFormatsExtended(_ProtoModel):
    """
    ValidatedFormatsExtended exercises additional format validators beyond the
    original six (email, uri, ip, ipv4, ipv6, uuid).
    """

    # Hostname must be a valid DNS hostname.
    hostname: _Annotated[str, _AfterValidator(_validate_hostname)] = _Field(
        description="Hostname must be a valid DNS hostname.",
    )
    # UriRef must be a valid URI reference (absolute or relative).
    uri_ref: _Annotated[str, _AfterValidator(_validate_uri_ref)] = _Field(
        description="UriRef must be a valid URI reference (absolute or relative).",
    )
    # Addr must be a valid IP address or hostname.
    addr: _Annotated[str, _AfterValidator(_validate_address)] = _Field(
        description="Addr must be a valid IP address or hostname.",
    )
    # Tuuid must be a trimmed UUID (32 hex chars, no dashes).
    tuuid: _Annotated[str, _AfterValidator(_validate_tuuid)] = _Field(
        description="Tuuid must be a trimmed UUID (32 hex chars, no dashes).",
    )
    # Ulid must be a valid ULID.
    ulid: _Annotated[str, _AfterValidator(_validate_ulid)] = _Field(
        description="Ulid must be a valid ULID.",
    )
    # Cidr must be a valid IP address with prefix length (host address).
    cidr: _Annotated[str, _AfterValidator(_validate_ip_with_prefixlen)] = _Field(
        description="Cidr must be a valid IP address with prefix length (host address).",
    )
    # CidrV4 must be a valid IPv4 address with prefix length.
    cidr_v4: _Annotated[str, _AfterValidator(_validate_ipv4_with_prefixlen)] = _Field(
        description="CidrV4 must be a valid IPv4 address with prefix length.",
    )
    # CidrV6 must be a valid IPv6 address with prefix length.
    cidr_v6: _Annotated[str, _AfterValidator(_validate_ipv6_with_prefixlen)] = _Field(
        description="CidrV6 must be a valid IPv6 address with prefix length.",
    )
    # IpNet must be a valid IP network (host bits must be zero).
    ip_net: _Annotated[str, _AfterValidator(_validate_ip_prefix)] = _Field(
        description="IpNet must be a valid IP network (host bits must be zero).",
    )
    # Ipv4Net must be a valid IPv4 network (host bits must be zero).
    ipv4_net: _Annotated[str, _AfterValidator(_validate_ipv4_prefix)] = _Field(
        description="Ipv4Net must be a valid IPv4 network (host bits must be zero).",
    )
    # Ipv6Net must be a valid IPv6 network (host bits must be zero).
    ipv6_net: _Annotated[str, _AfterValidator(_validate_ipv6_prefix)] = _Field(
        description="Ipv6Net must be a valid IPv6 network (host bits must be zero).",
    )
    # Endpoint must be a valid host:port pair.
    endpoint: _Annotated[str, _AfterValidator(_validate_host_and_port)] = _Field(
        description="Endpoint must be a valid host:port pair.",
    )

well_known_regex (HTTP header names and values)

string.well_known_regex validates HTTP header names and values per RFC 7230:

message ValidatedWellKnownRegex {
  // HeaderName must be a valid HTTP header name.
  string header_name = 1 [(buf.validate.field).string.well_known_regex = KNOWN_REGEX_HTTP_HEADER_NAME];
  // HeaderValue must be a valid HTTP header value.
  string header_value = 2 [(buf.validate.field).string.well_known_regex = KNOWN_REGEX_HTTP_HEADER_VALUE];
  // LooseHeader uses well_known_regex with strict=false (strict is not translated).
  string loose_header = 3 [(buf.validate.field).string = {
    well_known_regex: KNOWN_REGEX_HTTP_HEADER_NAME,
    strict: false
  }];
}
class ValidatedWellKnownRegex(_ProtoModel):
    """
    ValidatedWellKnownRegex exercises the well_known_regex enum validator.
    """

    # HeaderName must be a valid HTTP header name.
    header_name: _Annotated[str, _AfterValidator(_validate_http_header_name)] = _Field(
        description="HeaderName must be a valid HTTP header name.",
    )
    # HeaderValue must be a valid HTTP header value.
    header_value: _Annotated[str, _AfterValidator(_validate_http_header_value)] = (
        _Field(
            description="HeaderValue must be a valid HTTP header value.",
        )
    )
    # LooseHeader uses well_known_regex with strict=false (strict is not translated).
    loose_header: _Annotated[str, _AfterValidator(_validate_http_header_name)] = _Field(
        description="LooseHeader uses well_known_regex with strict=false (strict is not translated).",
        # buf.validate: strict=false (not translated)
    )

Note: strict=false (which loosens HTTP header validation) is not translated — the strict validator is always applied.

String not_contains

string.not_contains rejects strings that include a given substring:

message ValidatedNotContains {
  // Username must not contain "admin".
  string username = 1 [(buf.validate.field).string.not_contains = "admin"];
}
class ValidatedNotContains(_ProtoModel):
    """
    ValidatedNotContains exercises the string.not_contains constraint.
    """

    # Username must not contain "admin".
    username: _Annotated[
        str, _AfterValidator(_make_not_contains_validator("admin"))
    ] = _Field(
        default="",
        description='Username must not contain "admin".',
    )

Float / double in and not_in

float.in, double.in, float.not_in, and double.not_in work the same as their integer and string counterparts:

message ValidatedFloatIn {
  // Ratio must be one of the allowed values.
  float ratio = 1 [(buf.validate.field) = {float: {in: [0.25, 0.5, 0.75, 1.0]}}];
  // Score must not be a negative sentinel value.
  double score = 2 [(buf.validate.field) = {double: {not_in: [-1.0, -2.0]}}];
}
class ValidatedFloatIn(_ProtoModel):
    """
    ValidatedFloatIn exercises float.in and double.not_in constraints.
    """

    # Ratio must be one of the allowed values.
    ratio: _Annotated[
        float, _AfterValidator(_make_in_validator(frozenset({0.25, 0.5, 0.75, 1.0})))
    ] = _Field(
        description="Ratio must be one of the allowed values.",
    )
    # Score must not be a negative sentinel value.
    score: _Annotated[
        float, _AfterValidator(_make_not_in_validator(frozenset({-1.0, -2.0})))
    ] = _Field(
        default=0.0,
        description="Score must not be a negative sentinel value.",
    )

CEL expressions

(buf.validate.field).cel and option (buf.validate.message).cel let you write arbitrary validation logic in CEL. protoc-gen-pydantic transpiles CEL expressions to native Python validators at code-generation time — no CEL library is needed in generated code.

Expressions that cannot be transpiled are dropped gracefully: the field or message keeps its normal definition and a # buf.validate: cel id="…" (not translated: reason) comment is emitted.

uint32/uint64 field comparisons: CEL integer literals default to int64. Comparing a uint field with a plain literal like this > 0 will fail type-checking. Use the u suffix to write a uint literal instead: this > 0u, this >= 10u.

Field-level CEL

Field-level CEL receives the field value as this and must return either bool (the constraint fires when false) or string (empty = valid, non-empty = error message).

message ValidatedCELField {
  // Must be positive.
  int32 age = 1 [(buf.validate.field).cel = {
    id: "positive",
    expression: "this > 0",
    message: "age must be positive"
  }];
  // Must start with an uppercase letter.
  string name = 2 [(buf.validate.field).cel = {
    id: "uppercase_start",
    expression: "this.matches(\"^[A-Z]\")",
    message: "name must start with uppercase"
  }];
  // Two rules on one field — both are checked independently.
  string code = 3 [
    (buf.validate.field).cel = {
      id: "code_prefix",
      expression: "this.startsWith(\"X\")",
      message: "code must start with X"
    },
    (buf.validate.field).cel = {
      id: "code_len",
      expression: "this.size() > 2",
      message: "code must be longer than 2 chars"
    }
  ];
}
class ValidatedCELField(_ProtoModel):
    """
    ValidatedCELField exercises field-level CEL transpilation.
    """

    # Must be positive.
    age: _Annotated[
        int,
        _AfterValidator(_make_cel_validator(lambda v: v > 0, "age must be positive")),
    ] = _Field(
        default=0,
        description="Must be positive.",
    )
    # Must start with an uppercase letter.
    name: _Annotated[
        str,
        _AfterValidator(
            _make_cel_validator(
                lambda v: _cel_matches("^[A-Z]", v), "name must start with uppercase"
            )
        ),
    ] = _Field(
        default="",
        description="Must start with an uppercase letter.",
    )
    # Combined: both bool-returning and chained.
    code: _Annotated[
        str,
        _AfterValidator(
            _make_cel_validator(lambda v: (v).startswith("X"), "code must start with X")
        ),
        _AfterValidator(
            _make_cel_validator(
                lambda v: len(v) > 2, "code must be longer than 2 chars"
            )
        ),
    ] = _Field(
        default="",
        description="Combined: both bool-returning and chained.",
    )

cel_expression shorthand

cel_expression is a simplified form of the cel rule that omits the id and message fields — the expression string itself is used as the rule id, and the error message is left empty. It works at both the field level and the message level.

// Field-level: equivalent to cel = { id: "this > 0", expression: "this > 0" }.
message ValidatedCELExprField {
  int32 age = 1 [(buf.validate.field).cel_expression = "this > 0"];
}

// Message-level: equivalent to option (buf.validate.message).cel = { ... }.
message ValidatedCELExprMessage {
  int32 min_val = 1;
  int32 max_val = 2;
  option (buf.validate.message).cel_expression = "this.min_val <= this.max_val";
}
class ValidatedCELExprField(_ProtoModel):
    """
    ValidatedCELExprField — field-level cel_expression shorthand on an int32 field.
    """

    age: _Annotated[
        int, _AfterValidator(_make_cel_validator(lambda v: v > 0, "this > 0"))
    ] = _Field(
        default=0,
    )
class ValidatedCELExprMessage(_ProtoModel):
    """
    ValidatedCELExprMessage — message-level cel_expression cross-field rule.
    Exercises cel_expression on MessageRules.
    """

    min_val: int = _Field(default=0)
    max_val: int = _Field(default=0)

    @_model_validator(mode="after")
    def _validate_cel_this_min_val____this_max_val(self) -> "ValidatedCELExprMessage":
        if not (self.min_val <= self.max_val):
            raise ValueError("this.min_val <= this.max_val")
        return self

Multiple cel_expression entries on the same field or message are each transpiled to a separate validator — they are checked independently:

// Two independent bounds on one field.
message ValidatedCELExprFieldMulti {
  int32 score = 1 [
    (buf.validate.field).cel_expression = "this > 0",
    (buf.validate.field).cel_expression = "this <= 100"
  ];
}

// Two independent message-level rules.
message ValidatedCELExprMessageMulti {
  int32 a = 1;
  int32 b = 2;
  int32 c = 3;
  option (buf.validate.message).cel_expression = "this.a <= this.b";
  option (buf.validate.message).cel_expression = "this.b <= this.c";
}

Untranslatable cel_expression entries follow the same drop path as .cel rules — a # buf.validate: cel id="…" (not translated: reason) comment is emitted and no validator is generated for that entry.

Message-level CEL

Message-level CEL receives the whole message as this. Each rule becomes a @model_validator(mode="after") method. Use has(this.field) to check field presence (proto3 optional fields).

// Cross-field: min_val must be less than max_val.
message ValidatedCELCrossField {
  int32 min_val = 1;
  int32 max_val = 2;

  option (buf.validate.message).cel = {
    id: "min_less_than_max",
    expression: "this.min_val < this.max_val",
    message: "min_val must be less than max_val"
  };
}

// Presence check: at least one name field must be set.
message ValidatedCELHas {
  optional string first_name = 1;
  optional string last_name  = 2;

  option (buf.validate.message).cel = {
    id: "name_required",
    expression: "has(this.first_name) || has(this.last_name)",
    message: "at least one name field must be set"
  };
}
class ValidatedCELCrossField(_ProtoModel):
    """
    ValidatedCELCrossField exercises a cross-field numeric comparison.
    """

    min_val: int = _Field(default=0)
    max_val: int = _Field(default=0)

    @_model_validator(mode="after")
    def _validate_cel_min_less_than_max(self) -> "ValidatedCELCrossField":
        if not (self.min_val < self.max_val):
            raise ValueError("min_val must be less than max_val")
        return self
class ValidatedCELHas(_ProtoModel):
    """
    ValidatedCELHas exercises the has() presence macro.
    """

    first_name: str | None = _Field(default=None)
    last_name: str | None = _Field(default=None)

    @_model_validator(mode="after")
    def _validate_cel_name_required(self) -> "ValidatedCELHas":
        if not (
            "first_name" in self.model_fields_set
            or "last_name" in self.model_fields_set
        ):
            raise ValueError("at least one name field must be set")
        return self

has() vs != null: has(this.field) checks whether a field was explicitly set (i.e. it is in model_fields_set); this.field != null compares the field's value against null / None. For proto3 optional fields these behave identically in most cases, but has() is preferred because it matches proto3 presence semantics exactly.

Comprehensions

Five CEL comprehension macros are transpiled to Python generator expressions:

CEL macro Python equivalent Description
this.all(x, pred) all(pred for x in v) every element satisfies pred
this.exists(x, pred) any(pred for x in v) at least one element satisfies pred
this.exists_one(x, pred) sum(1 for x in v if pred) == 1 exactly one element satisfies pred
this.filter(x, pred) [x for x in v if pred] elements satisfying pred; chain .size() etc.
this.map(x, fn) [fn for x in v] transform every element; chain .all() etc.

Comprehensions can be nested — this.map(w, w.size()).all(l, l >= 3) transpiles to all((l >= 3) for l in [len(w) for w in v]).

message ValidatedCELAll {
  repeated int32 scores = 1 [(buf.validate.field).cel = {
    id: "all_positive",
    expression: "this.all(x, x > 0)",
    message: "all scores must be positive"
  }];
}
class ValidatedCELAll(_ProtoModel):
    """
    ValidatedCELAll exercises all() comprehension on a repeated field.
    """

    scores: _Annotated[
        list[int],
        _AfterValidator(
            _make_cel_validator(
                lambda v: all((x > 0) for x in v), "all scores must be positive"
            )
        ),
    ] = _Field(
        default_factory=list,
    )

Temporal expressions

now, duration("…"), and timestamp("…") are transpiled to Python datetime helpers. now evaluates to the current UTC time at validation time (not at code-generation time).

CEL Python Notes
now _cel_now() datetime.now(tz=timezone.utc)
duration("1h30m") _cel_duration(5400) Parsed at code-gen time; any Go time.ParseDuration format accepted
timestamp("2020-01-01T00:00:00Z") _cel_timestamp("2020-01-01T00:00:00Z") RFC 3339 string

Timestamp and Duration fields (Python datetime | None / timedelta | None) get a null-safe wrapper — v is None skips validation, matching protovalidate's semantics for absent message fields.

message ValidatedCELTimestamp {
  google.protobuf.Timestamp deadline = 1 [(buf.validate.field).cel = {
    id: "in_future",
    expression: "this > now",
    message: "deadline must be in the future"
  }];
}

message ValidatedCELDurationRange {
  google.protobuf.Duration ttl = 1 [(buf.validate.field).cel = {
    id: "ttl_in_range",
    expression: "this >= duration(\"1m\") && this <= duration(\"1h\")",
    message: "ttl must be between 1 minute and 1 hour"
  }];
}
class ValidatedCELTimestamp(_ProtoModel):
    """
    ValidatedCELTimestamp — 'this > now' on a Timestamp field.
    """

    deadline: (
        _Annotated[
            ProtoTimestamp,
            _AfterValidator(
                _make_cel_validator(
                    lambda v: v is None or (v > _cel_now()),
                    "deadline must be in the future",
                )
            ),
        ]
        | None
    ) = _Field(
        default=None,
    )
class ValidatedCELDurationRange(_ProtoModel):
    """
    ValidatedCELDurationRange — duration bounded between two literals.
    """

    ttl: (
        _Annotated[
            ProtoDuration,
            _AfterValidator(
                _make_cel_validator(
                    lambda v: (
                        v is None
                        or ((v >= _cel_duration(60)) and (v <= _cel_duration(3600)))
                    ),
                    "ttl must be between 1 minute and 1 hour",
                )
            ),
        ]
        | None
    ) = _Field(
        default=None,
    )

Timestamp and duration member accessors

Timestamp getters receive the datetime value and an optional IANA timezone string (default: UTC). Duration getters return total units (not calendar components).

Timestamp (google.protobuf.Timestampdatetime):

CEL Python Notes
this.getFullYear() v.year 4-digit year
this.getMonth() (v.month - 1) 0-indexed (January = 0)
this.getDayOfMonth() (v.day - 1) 0-indexed (1st = 0)
this.getDayOfYear() (v.timetuple().tm_yday - 1) 0-indexed (Jan 1 = 0)
this.getDayOfWeek() (v.isoweekday() % 7) Sun=0, Mon=1, …, Sat=6
this.getHours() v.hour 0–23, UTC unless tz given
this.getMinutes() v.minute 0–59
this.getSeconds() v.second 0–59
this.getMilliseconds() (v.microsecond // 1000) 0–999
this.getHours("America/New_York") _cel_ts_in_tz(v, "America/New_York").hour IANA timezone arg

Duration (google.protobuf.Durationtimedelta):

CEL Python Notes
this.getHours() _cel_dur_get_hours(v) (v.days * 86400 + v.seconds) // 3600 — total hours
this.getMinutes() _cel_dur_get_minutes(v) (v.days * 86400 + v.seconds) // 60 — total minutes
this.getSeconds() _cel_dur_get_seconds(v) v.days * 86400 + v.seconds — total seconds
this.getMilliseconds() _cel_dur_get_milliseconds(v) total milliseconds

Boolean format helpers

CEL boolean predicates map to the same _proto_types.py helpers used by the predefined format validators:

CEL Helper Same as predefined
this.isEmail() _is_email(v) string.email
this.isUri() _is_uri(v) string.uri
this.isUriRef() _is_uri_ref(v) string.uri_ref
this.isIp() _is_ip(v) string.ip
this.isIp(4) _is_ip(v, 4) string.ipv4
this.isIp(6) _is_ip(v, 6) string.ipv6
this.isIpPrefix() _is_ip_prefix(v) string.ip_prefix
this.isIpPrefix(4) _is_ip_prefix(v, 4) string.ipv4_prefix
this.isIpPrefix(6) _is_ip_prefix(v, 6) string.ipv6_prefix
this.isHostname() _is_hostname(v) string.hostname
this.isHostAndPort(true) _is_host_and_port(v, True) string.host_and_port
this.isNan() _is_nan(v)
this.isInf() _is_inf(v)
this.isInf(1) _is_inf(v, 1) positive infinity only
this.isInf(-1) _is_inf(v, -1) negative infinity only

Unsupported expressions

CEL constructs that cannot be transpiled are dropped with a comment rather than causing a build error. The generated field keeps its default definition and a comment records the rule that was not translated:

# buf.validate: cel id="rule_id" (not translated: reason)

Currently dropped:

Construct Example
ext.Strings() member functions this.lowerAscii(), this.trim(), this.split(",")
Two-variable map comprehensions this.all(k, v, v > 0)
rules ident this > rules.min
getField() getField(this, "name")
Non-literal duration()/timestamp() args duration(this.timeout_str)
Non-literal timezone arg this.getHours(this.tz)

Constraints not translated

The following constraints have no direct Pydantic equivalent and are emitted as comments inside _Field() so they remain visible to developers:

Constraint Reason
required on message-typed or plain scalar fields No Pydantic equivalent for proto3 plain scalars
bytes.const Literal[bytes] is not supported
duration.gt / timestamp.lte / etc. Message-typed bounds have no Field() equivalent
CEL with unsupported constructs ext.Strings() functions, two-variable comprehensions, rules ident, getField(), non-literal duration()/timestamp() arguments — see Unsupported expressions
message ValidatedDropped {
  // Name is required; the required constraint is not translated.
  string name = 1 [(buf.validate.field).required = true];
  // Blob has a bytes.const constraint which is not translated (bytes kind unsupported).
  bytes blob = 2 [(buf.validate.field).bytes.const = "\x01"];
  // Score must be positive; required is also set but not translated.
  int32 score = 3 [(buf.validate.field).required = true, (buf.validate.field).int32.gt = 0];
}
class ValidatedDropped(_ProtoModel):
    """
    ValidatedDropped exercises constraints that are recognised but not translated.
    """

    # Name is required; the required constraint is not translated.
    name: str = _Field(
        default="",
        description="Name is required; the required constraint is not translated.",
        # buf.validate: required (not translated)
    )
    # Blob has a bytes.const constraint which is not translated (bytes kind unsupported).
    blob: bytes = _Field(
        default=b"",
        description="Blob has a bytes.const constraint which is not translated (bytes kind unsupported).",
        # buf.validate: const (not translated)
    )
    # Score must be positive; required is also set but not translated.
    score: int = _Field(
        description="Score must be positive; required is also set but not translated.",
        gt=0,
        # buf.validate: required (not translated)
    )

Zero-value validation

In proto3, non-optional scalar fields always have a zero value ("" for strings, 0 for integers and floats, false for booleans, b"" for bytes). If a field's constraints reject that zero value, the generator makes the field required in the generated Pydantic model — construction without an explicit value raises ValidationError.

This affects fields with:

  • Format validators (string.email, string.uri, string.ip, bytes.ip, bytes.uuid, etc.)
  • gt = N where N ≥ 0, or gte = N where N > 0
  • string.min_len = N where N > 0
  • string.min_bytes = N where N > 0
  • string.len_bytes = N where N > 0
  • string.pattern (any pattern rejects the empty string)
  • in constraints where the zero value is not a member of the allowed set

Fields with const constraints, repeated/map fields, optional proto3 fields, and oneof members are not affected.

Opting out with ignore = IGNORE_IF_ZERO_VALUE

To allow the zero value even when constraints would reject it, annotate the field with ignore = IGNORE_IF_ZERO_VALUE. The generated field keeps its zero default, and validators only run for explicitly-provided values:

message ValidatedIgnore {
  // Email allows empty string via ignore (not required).
  string email = 1 [
    (buf.validate.field).string.email = true,
    (buf.validate.field).ignore = IGNORE_IF_ZERO_VALUE
  ];
  // Age allows zero via ignore (not required).
  int32 age = 2 [
    (buf.validate.field).int32.gt = 0,
    (buf.validate.field).ignore = IGNORE_IF_ZERO_VALUE
  ];
}
class ValidatedIgnore(_ProtoModel):
    """
    ValidatedIgnore exercises ignore = IGNORE_IF_ZERO_VALUE, which opts a field
    out of ConstrainedRequired even when its constraints reject the zero value.
    """

    # Email allows empty string via ignore (not ConstrainedRequired).
    email: _Annotated[str, _AfterValidator(_validate_email)] = _Field(
        default="",
        description="Email allows empty string via ignore (not ConstrainedRequired).",
    )
    # Age allows zero via ignore (not ConstrainedRequired).
    age: int = _Field(
        default=0,
        description="Age allows zero via ignore (not ConstrainedRequired).",
        gt=0,
    )

enum.defined_only

enum.defined_only = true is a no-op in generated Python — Python enums already enforce this natively by only accepting defined member values.