CEL Validation in CRDs: Practical Rules for Operator APIs

Tech reviewed: Deepak Prasad
CEL Validation in CRDs: Practical Rules for Operator APIs

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.

Kubernetes CRD validation pipeline showing a Custom Resource request passing through OpenAPI schema validation, CEL rules, and admission before being stored in etcd, with webhooks used only when external cluster state is required


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 hostPath or pvcName may be set
  • endpoint is required when type is External

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:

yaml
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 migrationPhase to move from PendingRunningCompleted;
  • preventing storageClass changes after the workload is deployed;
  • blocking a downgrade from Enterprise to Standard when 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, numeric minimum / maximum, pattern (regex with care).
  • Single-field constraints cheap for clients and documentation (kubectl explain).

CEL excels at

  • Cross-field rules: “if profile: Production, debug must be false.”
  • Mutual exclusion: “at most one of hostPath and pvcName.”
  • Conditional required: “if type == External, endpoint is required.”
  • Immutability and allowed transitions without exploding oneOf trees.
  1. OpenAPI for everything it expresses cleanly (types, enums, per-field bounds).
  2. CEL for relationships between fields and lifecycle constraints.
  3. 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:

yaml
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".

yaml
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 upgradeStrategy to move between supported modes;
  • restricting migrationPhase transitions 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.endpoint when spec.type is External”).
  • Avoid leaking internal enum names unless they are user-facing API values.

message vs messageExpression

  • Use message for static, reviewed copy.
  • Use messageExpression when 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 bounded messageExpression).
  • Immutability tested with update, not only create.
  • OpenAPI still carries types/enums for generators and kubectl explain.
  • Minimum Kubernetes version documented for oldObject / oldSelf usage.
  • No secrets or unbounded blobs referenced in rules for “size” checks—prefer OpenAPI maxItems / maxLength where 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 under spec 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 emit x-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


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.

Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with over a decade of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, …

  • Red Hat Certified System Administrator in Red Hat OpenStack
  • Certified Kubernetes Application Developer (CKAD)
  • Red Hat Certified Specialist in Ansible Automation
  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security