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:
Lock the dependency:
Import the validate file in your 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 > 0andlen_bytes > 0trigger zero-value validation —payload,token, andtagabove 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
optionalin proto3 or annotate it withignore = 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:
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)¶
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¶
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 optionalscalar: strips| Nonefrom the type — the field becomes required at the Pydantic level (no default).proto3 optionalscalar + 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:
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:
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/uint64field comparisons: CEL integer literals default toint64. Comparing auintfield with a plain literal likethis > 0will fail type-checking. Use theusuffix 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 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 inmodel_fields_set);this.field != nullcompares the field's value againstnull/None. For proto3optionalfields these behave identically in most cases, buthas()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]).
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.Timestamp → datetime):
| 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.Duration → timedelta):
| 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:
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 = Nwhere N ≥ 0, orgte = Nwhere N > 0string.min_len = Nwhere N > 0string.min_bytes = Nwhere N > 0string.len_bytes = Nwhere N > 0string.pattern(any pattern rejects the empty string)inconstraints 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.