CEL in Kubernetes

Common Expression Language (CEL) is the small, embeddable, non-Turing-complete expression language that Kubernetes adopted as its in-process engine for validation and policy — the strategic replacement for out-of-process admission webhooks across the policy use case (kubernetes.io — CEL). CEL is a Google-originated open language (google/cel-spec); Kubernetes embeds a CEL interpreter directly inside kube-apiserver. The motivation is operational fragility: a ValidatingAdmissionWebhook is a separate HTTPS service on the API server’s hot path — if it is slow, down, or misconfigured, every matching create/update either hangs or is rejected, and a cluster can be wedged by a single bad webhook. CEL moves the expression-evaluable subset of that work inside the API server: no network hop, no separate Pod, no TLS plumbing, no availability dependency. Because CEL is non-Turing-complete — no unbounded loops, no recursion — every expression is guaranteed to terminate, which is the property that makes it safe to evaluate synchronously in the API server’s request path. CEL now drives Custom Resource Definition validation rules, ValidatingAdmissionPolicy and MutatingAdmissionPolicy, structured authorization match conditions, and admission-webhook match conditions. Understanding CEL is understanding the direction K8s extensibility is heading: declarative, in-process, webhook-free policy.

Mental Model

The thing to internalize is why a deliberately limited language is a feature, not a compromise. A general-purpose language (a webhook written in Go, an OPA Rego policy) can do anything — including loop forever, recurse without bound, or take arbitrarily long. None of that can be allowed on the synchronous path of an API server that must answer thousands of requests per second. CEL’s restrictions are exactly the price of admission to that hot path:

flowchart TD
    REQ["API request:<br/>create / update an object"]
    subgraph APISERVER["inside kube-apiserver — synchronous, hot path"]
        CEL["CEL evaluator<br/>non-Turing-complete<br/>guaranteed to terminate<br/>cost-budget checked"]
        WEBHOOK["...vs an admission webhook:<br/>network hop to a separate Pod<br/>can hang / be down / time out"]
    end
    PASS["admit"]
    FAIL["reject with message"]

    REQ --> CEL
    CEL -- "expression true" --> PASS
    CEL -- "expression false" --> FAIL
    REQ -. "the old way" .-> WEBHOOK
    WEBHOOK -. "adds latency +<br/>an availability dependency" .-> PASS

The diagram contrasts in-process CEL evaluation with the webhook it replaces. The insight to extract: CEL trades generality for two guarantees the API server’s hot path requires — bounded execution time (non-Turing-completeness ⇒ termination) and no external dependency (the evaluator is in-process). A webhook can do more, but it adds a network hop and a way for the whole cluster to break. CEL is the language you get when you ask “what is the most expressive policy language that is still safe to run inside the API server on every request?”

Mechanical Walk-through

Where CEL is used

CEL is not one feature; it is one engine wired into several extension points (kubernetes.io — CEL):

  • CRD validation rules — the x-kubernetes-validations field inside a CRD’s OpenAPI schema. Each rule is a CEL expression that must evaluate true for the custom resource to be admitted. Alpha in 1.23, beta in 1.25, and GA in Kubernetes 1.29 (per KEP-2876’s stage: stable, latest-milestone: v1.29). This is the most widely used CEL surface.
  • ValidatingAdmissionPolicy — a cluster-wide, CEL-based admission validation mechanism for any resource (not just custom resources). Alpha in 1.26, GA in 1.30 (Kubernetes blog, April 24 2024). The in-process replacement for the ValidatingAdmissionWebhook.
  • MutatingAdmissionPolicy — the mutation-side sibling: CEL expressions that modify objects on admission. Alpha in 1.32, beta in 1.34, GA in 1.36 (per KEP-3962’s implementation history).
  • Structured authorization configurationmatch conditions in the API server’s authorizer chain (e.g. limiting a webhook authorizer to certain requests) are CEL expressions.
  • Admission webhook match conditions — even when you still use a webhook, its matchConditions are CEL expressions evaluated in-process to decide whether the webhook is called at all — narrowing a webhook’s blast radius without a round trip.

The language properties

  • Non-Turing-complete. No unbounded loops, no recursion. The macros that look loop-like (all, exists, map, filter) iterate over finite collections drawn from the object being validated. Termination is guaranteed by construction — this is the load-bearing property.
  • Single expression per program. A CEL “program” is one expression evaluating to one value (for validation, a boolean).
  • C-family syntax. Resembles C/C++/Java/Go/JavaScript expressions — &&, ||, ==, <=, ternary ? :, member access, function calls.
  • Object-scoped. All Kubernetes CEL rules are scoped to the current object only — there is no cross-object or stateful validation. A CRD rule cannot say “this name must be unique cluster-wide”; that still needs a webhook with a list call.

The variables

The variable bindings depend on where the CEL expression lives:

  • CRD validation rulesself is bound to the value at the schema location where the x-kubernetes-validations extension sits (the whole object if at the schema root, a sub-field if nested). oldSelf is bound to the previous value, available only in transition rules (rules that compare old and new — e.g. enforcing immutability). The macro has() tests field presence; oldSelf lets a rule express “this field may not change after creation.”
  • ValidatingAdmissionPolicy / MutatingAdmissionPolicy / webhook match conditionsobject (the incoming object; null for DELETE), oldObject (the prior state; null for CREATE), request (the AdmissionRequest metadata — operation, user, namespace), params if the policy is parameterized via a paramKind, namespaceObject (the Namespace the resource lives in, null for cluster-scoped resources), authorizer (a CEL Authorizer that can perform SubjectAccessReview-style checks against the requesting principal), and author-declared named variables accessed as variables.foo (kubernetes.io — MutatingAdmissionPolicy).

Cost limits — the DoS guard

Non-Turing-completeness guarantees termination but not speed — a CEL expression iterating a million-element list still costs real CPU. So Kubernetes assigns every CEL operation an estimated cost and enforces a cost budget (KEP-2876). There are two checks:

  1. Estimated cost, at write time. When a CRD (or policy) is created or updated, the API server statically estimates the worst-case cost of each CEL rule, using the schema’s maxItems/maxLength/maxProperties bounds to bound iteration. A rule whose estimated cost exceeds the per-expression limit is rejected at write time — you cannot even install a CRD with an unboundedly expensive rule. This is why CEL rules over lists/maps generally require the schema to declare size limits.
  2. Runtime cost, at evaluation time. Each actual evaluation is also metered against a runtime budget; an evaluation that exceeds it is aborted and the request fails. This catches cases the static estimate could not bound.

The effect: CEL cannot be used to DoS the API server, because both the installable set of rules and the per-request evaluation are budget-capped.

Configuration / API Surface

CEL validation rules inside a CRD

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: webservices.example.com
spec:
  group: example.com
  names: { kind: WebService, plural: webservices }
  scope: Namespaced
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                replicas:    { type: integer }
                minReplicas: { type: integer }
                maxReplicas: { type: integer }
                tier:        { type: string }
                containers:
                  type: array
                  maxItems: 20                           # bounds the cost of any rule iterating it
                  items:
                    type: object
                    properties:
                      name:   { type: string }
                      memory: { type: string }
              # object-level rules: 'self' = the whole spec object
              x-kubernetes-validations:
                - rule: "self.minReplicas <= self.replicas && self.replicas <= self.maxReplicas"
                  message: "replicas must be between minReplicas and maxReplicas"
                - rule: "self.containers.all(c, c.memory != '')"
                  message: "every container must declare a memory value"
                - rule: "self.tier != 'prod' || self.replicas >= 3"
                  message: "prod tier requires at least 3 replicas"
            # transition rule: uses oldSelf — fires only on UPDATE
            tier:
              type: string
              x-kubernetes-validations:
                - rule: "self == oldSelf"
                  message: "tier is immutable once set"

Line-by-line: the first object-level rule cross-validates three sibling fields in one expression — something a plain OpenAPI schema cannot do (OpenAPI can bound a single field but not relate fields). self is the spec object because the x-kubernetes-validations sits at the spec schema node. The all(c, ...) macro iterates the containers list — and this is only installable because containers declares maxItems: 20, which lets the API server bound the rule’s estimated cost; without the bound, the CRD would be rejected at write time. The third rule is a conditional: “if tier is prod, then replicas >= 3.” The tier-level rule is a transition rule — it references oldSelf, so it is evaluated only on updates, and self == oldSelf enforces immutability.

CEL in a ValidatingAdmissionPolicy

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: replica-limit
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: ["apps"]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["deployments"]
  validations:
    - expression: "object.spec.replicas <= 5"
      message: "Deployments may not exceed 5 replicas"
    - expression: >-
        object.spec.template.spec.containers.all(c,
          has(c.resources.limits) && has(c.resources.limits.memory))
      message: "every container must set a memory limit"

Here the variable is object (not self) — the convention for admission policies. The first expression caps replica count on built-in Deployments, something CRD validation rules cannot do because Deployments are not custom resources. The second walks containers with all(...) and has(...) to require memory limits — the canonical “enforce resource limits” policy, now expressible in-process with no webhook. See ValidatingAdmissionPolicy.

Failure Modes

  • Unbounded-cost rule rejected at CRD install. A rule iterating a list/map whose schema does not declare maxItems/maxProperties has an unbounded estimated cost and the API server refuses to create the CRD. Symptom: CustomResourceDefinition.apiextensions.k8s.io is invalid: ... exceeds budget. Fix: add size bounds to the schema.
  • Runtime cost-budget abort. A rule that passed static estimation but is expensive on a particular large object aborts mid-evaluation; the request fails. Rare with bounded schemas; a sign of an over-clever expression.
  • Transition-rule confusion. A rule referencing oldSelf runs only on update; authors expecting it to fire on create are surprised. Conversely, a rule that should be immutability-checking but omits oldSelf does nothing useful.
  • Null / missing-field panics. CEL evaluation of self.foo.bar when foo is absent errors. Idiomatic CEL guards with has(self.foo) && self.foo.bar == .... Forgetting the has() guard is the most common authoring bug.
  • Expecting cross-object validation. CEL rules are object-scoped — “this value must be unique across the namespace” or “this must match another resource” is not expressible. That still requires a webhook with API calls.
  • Library-version skew. Some CEL libraries are version-gated (Quantity 1.29+, IP and CIDR 1.31+, Format 1.32+, Strings v2 1.30+, Semver 1.34+ — confirmed against the CEL library table on kubernetes.io). A rule using a newer library on an older API server fails to parse. Match library use to the cluster’s minor version.

Alternatives and When to Choose Them

  • ValidatingAdmissionWebhook / MutatingAdmissionWebhook — still required for anything CEL cannot do: cross-object validation, external lookups (LDAP, a database), stateful decisions, or logic too complex for a single expression. The cost is the operational fragility CEL was created to escape. Use a webhook only when the in-process options genuinely cannot express the policy.
  • OPA Gatekeeper / Kyverno — full policy engines (themselves implemented as admission webhooks). They offer policy libraries, audit/reporting, mutation, resource generation, and richer authoring than raw CEL. Notably, Gatekeeper and Kyverno can generate ValidatingAdmissionPolicy objects — i.e. use CEL as their enforcement backend while keeping their authoring/reporting UX. Choose a full engine when you need a managed policy catalog and reporting; choose raw CEL policies when you want a handful of rules with zero extra components.
  • Plain OpenAPI v3 schema validationminimum, maximum, pattern, enum, required. Always prefer this for single-field constraints; it is simpler and cheaper than CEL. Reach for CEL only when a constraint relates multiple fields or needs conditional logic.
  • Application-level validation — validating in the controller’s Reconcile rather than at admission. The downside: the bad object is already persisted; admission-time rejection (CEL or webhook) is strictly better when feasible.

Production Notes

  • Why CEL matters strategically. CEL is the foundation of Kubernetes’ deliberate move away from webhook-based extensibility for the policy use case. The progression — CRD validation rules (GA 1.29), ValidatingAdmissionPolicy (GA 1.30), MutatingAdmissionPolicy (GA 1.36) — is SIG-API-Machinery systematically replacing the webhook-shaped surfaces with in-process CEL ones. The webhook is not disappearing, but for “reject/mutate based on the object’s contents” it is being demoted from default to last resort.
  • The CEL Playground (playcel.undistro.io) is the standard tool for iterating on expressions before embedding them in a CRD or policy — far faster than apply-and-see.
  • Gatekeeper and Kyverno both adopted CEL. Their convergence on CEL as an enforcement backend is the strongest signal of CEL’s role: even the established policy engines now generate ValidatingAdmissionPolicy to get in-process evaluation, treating CEL as the substrate rather than a competitor.
  • +kubebuilder:validation:XValidation marker. Operator authors using Kubebuilder do not hand-write x-kubernetes-validations YAML — they put a // +kubebuilder:validation:XValidation:rule="...",message="..." marker on the Go struct and controller-gen emits the CEL rule into the generated CRD. CEL validation is part of the standard operator-authoring workflow.
  • Do not confuse with “declarative validation” for native types. Kubernetes 1.36 also shipped Declarative Validation for built-in/native types to GA (Kubernetes blog, May 5 2026), a validation-gen/+k8s: marker framework that replaces the API server’s hand-written Go validation functions with generated ones. That is a separate feature from the CEL-driven extension points in this note — it modernizes how the API server’s internal validation for first-party types (Pod, Deployment, etc.) is implemented, not what user-supplied policies can express. The two efforts share a “declarative, marker-driven” aesthetic but are otherwise distinct: CEL is for user-authored policies on any resource; declarative validation is for project-maintained validation of built-in types.

Uncertain

Verify: the exact numeric per-expression estimated-cost limit and per-request runtime cost-budget limit. Reason: the kubernetes.io CEL reference describes the mechanism (static estimate vs runtime metering) and KEP-2876 confirms the design (“we will set cost bounds … on both a per-request and per-expression basis as we perform further testing and benchmarking”) without committing to specific numbers; the actual constants live in the API server source and have changed across minors. To resolve: cross-reference staging/src/k8s.io/apiserver/pkg/cel/ in the kubernetes/kubernetes repo for the constants in the relevant minor, or read the API server’s startup logs for the effective budget. The operational rule — “rules over collections require maxItems/maxLength/maxProperties on the schema or the CRD is rejected at install time” — is the reliable bit; the specific number behind it is not.

All other version-maturity claims in this note (CRD x-kubernetes-validations: alpha 1.23, beta 1.25, GA 1.29; ValidatingAdmissionPolicy: alpha 1.26, GA 1.30; MutatingAdmissionPolicy: alpha 1.32, beta 1.34, GA 1.36; CEL library minors) have been verified against primary KEPs and kubernetes.io blog posts as cited inline. Re-verify against the live docs for any operational decision because the docs are point-in-time as of 2026-05-30. uncertain

See Also