Custom Resource Definitions give your operator a typed API. OpenAPI v3 structural schemas catch shape errors early; Common Expression Language (CEL) rules in x-kubernetes-validations catch cross-field intent, immutability, and state transitions that are awkward or impossible with schema keywords alone—without running a webhook.
This guide is for operator CRD authors using structural schemas (Kubebuilder, Operator SDK, or hand-written YAML). It does not replace validating admission webhooks when you need external lookups—CEL stays fast, local, and deterministic.
CEL validation in 60 seconds
- Use OpenAPI for types, enums, ranges, and single-field constraints.
- Use CEL for relationships between fields, conditional requirements, immutability, and allowed transitions.
- Use a validating webhook when the rule depends on other Kubernetes objects or external systems.
- Keep CEL expressions short and pair every rule with a clear error message.
- Test validation rules on the oldest Kubernetes version your operator supports.
x-kubernetes-validations basics
Validation rules live on schema nodes—often under openAPIV3Schema.properties.spec (or nested properties). Each rule is an object with:
| Field | Role |
|---|---|
rule |
CEL expression that must evaluate to true for the request to succeed. |
message |
Static human-readable text when the rule fails. |
messageExpression |
CEL string expression for dynamic messages (keep output bounded). |
fieldPath |
Optional path (relative to the schema node) shown with the error. |
reason |
Optional machine-oriented reason (e.g. Invalid). |
The apiserver evaluates rules on create and update (including kubectl apply --dry-run=server), so misconfigurations fail before your reconciler runs.
CEL environment (what you can reference)
CEL expressions operate on the object currently being admitted. At the schema root, rules can reference the entire object; at nested schema nodes, self usually represents the value at that location.
For update operations, variables such as oldSelf (and, on supported Kubernetes versions and scopes, oldObject) allow rules to compare the new request with the previously stored object.
Cost and limits
CEL evaluation is bounded—the apiserver applies limits to compilation and runtime cost to prevent expensive rules from affecting control-plane stability.
Prefer multiple small rules with focused error messages over one large expression that mixes many conditions. For example, two separate rules saying:
- only one of
hostPathorpvcNamemay be set endpointis required whentypeisExternal
are easier to understand and maintain than a single complex expression that handles every case.
Immutability and transition rules (oldSelf, oldObject)
Immutability after create
For a field that may be set on create but not changed on update, compare against the prior value. At a leaf property, oldSelf is the previous value of that field:
region:
type: string
x-kubernetes-validations:
- rule: "!has(oldSelf) || self == oldSelf"
message: "region is immutable after the resource is created"!has(oldSelf) covers create (no prior value); self == oldSelf allows idempotent no-op updates.
Whole-object transitions
When rules are attached higher in the schema tree (for example under spec), oldObject allows comparisons between the previous and new object state on Kubernetes versions that support it.
This is useful for transition rules such as:
- allowing
migrationPhaseto move fromPending→Running→Completed; - preventing
storageClasschanges after the workload is deployed; - blocking a downgrade from
EnterprisetoStandardwhen the current replica count exceeds the lower tier limits.
Always verify your minimum supported Kubernetes version against the feature availability in CRD validation rules. If older clusters must be supported, keep advanced transition checks in a validating webhook or document which CEL features are required.
Defaulting and validation order
Defaulting and conversion happen before CEL validation. A rule evaluates the final object that would be stored, not the raw YAML submitted by the user.
For example, if a CRD default sets spec.replicas: 3, a CEL rule will see replicas as 3 even when the user omitted the field.
Document defaults clearly so users understand why a request passed or failed validation.
SSA and field managers
Server-Side Apply introduces multiple field managers and ownership conflicts, but CEL validation should express the API contract rather than implementation details.
Good validation messages describe intent:
networkMode cannot change after creation
Avoid messages that expose internal mechanics:
field owned by manager X cannot be modified
Use Server-Side Apply conflict handling and field ownership rules as a separate operational concern—see SSA in operators.
OpenAPI-only validation vs CEL; use both
OpenAPI excels at
- Types,
required,enum,minLength/maxLength,format, numericminimum/maximum,pattern(regex with care). - Single-field constraints cheap for clients and documentation (
kubectl explain).
CEL excels at
- Cross-field rules: “if
profile: Production,debugmust be false.” - Mutual exclusion: “at most one of
hostPathandpvcName.” - Conditional required: “if
type == External,endpointis required.” - Immutability and allowed transitions without exploding
oneOftrees.
Recommended layering
- OpenAPI for everything it expresses cleanly (types, enums, per-field bounds).
- CEL for relationships between fields and lifecycle constraints.
- Admission webhook only when you need non-local facts (quota, external registry, other objects).
Anti-pattern: encoding a small enum as a giant CEL self in ['a','b'] when enum: [a, b] is clearer for clients and OpenAPI generators.
Common patterns (replicas, mutual exclusion, versions)
Numeric bounds (OpenAPI first; CEL when cross-field)
Use OpenAPI minimum and maximum when a limit is always true. They are simpler, appear in generated API documentation, and are understood by client tooling.
Use CEL on a parent object when the valid range depends on another field—for example, allowing larger replica counts only for an Enterprise tier:
spec:
type: object
properties:
replicas:
type: integer
minimum: 1
maximum: 100
tier:
type: string
enum: [Standard, Enterprise]
x-kubernetes-validations:
- rule: "!has(self.replicas) || self.replicas <= 10 || self.tier == 'Enterprise'"
message: "more than 10 replicas requires spec.tier Enterprise"(Paths assume this block is under the CRD’s spec schema for your version; adjust nesting to match your CRD.)
Mutual exclusion
CEL is useful for expressing relationships such as "exactly one of these fields must be set".
x-kubernetes-validations:
- rule: "!has(self.hostPath) || !has(self.pvcName)"
message: "set only one of hostPath or pvcName"
- rule: "has(self.hostPath) || has(self.pvcName)"
message: "specify either hostPath or pvcName"Together these rules enforce an exactly-one-of constraint:
- Both missing → rejected.
- Both present → rejected.
- Only one present → accepted.
The same pattern can model conditional requirements such as "if type is External, endpoint must be set".
Version fields and migration state
CEL validates the object currently being admitted; it does not replace CRD storage version or API conversion mechanics.
Use CEL for user-facing API transitions such as:
- allowing
upgradeStrategyto move between supported modes; - restricting
migrationPhasetransitions when the phase is part of the desired API contract.
Keep conversion webhooks responsible for translating incompatible object shapes between API versions.
Be careful not to put controller-owned runtime state into spec just to validate transitions. Long-running progress states usually belong in status, while spec should represent the user's desired configuration.
Error messages operators and users should see
Write messages for humans
- State what is wrong, which fields interact, and how to fix (“set
spec.endpointwhenspec.typeisExternal”). - Avoid leaking internal enum names unless they are user-facing API values.
message vs messageExpression
- Use
messagefor static, reviewed copy. - Use
messageExpressionwhen the message must include bounded dynamic detail (for example listing allowed enum values from the same object—still keep output short).
Surfacing errors beyond kubectl
The apiserver returns Invalid with field paths—UIs and CI should show the same strings. In reconcile, do not re-validate what CEL already guarantees unless you need drift detection; for admission failures, rely on client errors. For async problems (image pull, quota), use status conditions with complementary wording so users do not confuse schema errors with runtime errors.
Consistency with webhooks
If the same product rule exists in CEL and a validating webhook (rare—prefer one layer), align wording and reason codes so support teams see one vocabulary.
Compatibility with older Kubernetes clusters
Document a minimum Kubernetes minor version
CEL features such as oldObject, reason, fieldPath, messageExpression, and evaluation limits evolve across Kubernetes releases. Your operator documentation and CSV should explicitly state the minimum Kubernetes version required by your CRDs.
Do not only test against the newest Kubernetes release. A CRD that installs and validates correctly on a developer cluster running Kubernetes 1.33 may fail to install or behave differently on a customer cluster running an older minor version.
Test compatibility in CI
Run envtest or kind against the oldest supported Kubernetes minor version with fixture CRs that should both pass and fail validation.
Test the complete lifecycle:
- CRD installation succeeds.
- Valid objects are accepted.
- Invalid objects are rejected with the expected error messages.
- Upgrade paths preserve compatibility when CRDs evolve.
When CEL is not enough
Version compatibility is not the only reason to avoid CEL. Some validation problems require information outside the admitted object.
Use a validating admission webhook when you need:
- Other Kubernetes resources (Pods, Secrets, Deployments, quotas, etc.).
- External systems such as registries, databases, or cloud APIs.
- Complex logic that would make CEL expressions difficult to maintain.
Use conversion webhooks when you need to translate incompatible object shapes between CRD API versions. CEL validates a single admitted object—it does not replace storage version migration or API conversion.
Checklist before you ship CEL-heavy CRDs
- Each rule has a clear
message(or boundedmessageExpression). - Immutability tested with update, not only create.
- OpenAPI still carries types/enums for generators and
kubectl explain. - Minimum Kubernetes version documented for
oldObject/oldSelfusage. - No secrets or unbounded blobs referenced in rules for “size” checks—prefer OpenAPI
maxItems/maxLengthwhere possible.
Frequently Asked Questions
1. Can CEL in a CRD call the Kubernetes API or read other resources?
No. CRD validation CEL runs inside the apiserver against the submitted object (and, on updates, the prior object where supported). Rules cannot list Pods, query DNS, or call webhooks. Anything that needs the cluster state or external systems belongs in a validating admission webhook or in reconcile with status feedback.2. Do validation rules run on status updates?
OpenAPI and CEL rules are attached to schema nodes. Fields underspec are not re-validated when only status changes if status is a separate subresource—design your rules on spec (and metadata if you must) so status writers do not trip spec-only constraints. Always test with kubectl apply to status as well as spec.3. How does this relate to kubebuilder markers?
Kubebuilder can emitx-kubernetes-validations from Go marker comments such as +kubebuilder:validation:XValidation. You can also hand-edit the CRD YAML. Pick one ownership model per field so make manifests does not erase hand-written rules.See also
- Kubernetes CRDs explained
- CRD version upgrades and conversion webhooks
- Validating and mutating admission webhooks
- Status subresource and conditions
- Server-Side Apply in operators
- Kubernetes docs: CRD structural schema & validation rules
Bottom line: put OpenAPI where it is strongest for shape, add x-kubernetes-validations for cross-field, immutability, and transition logic, reserve webhooks for cluster or external truth, and ship messages and version gates as part of the API contract—not as an afterthought.

