# Kyverno 1.20: legacy ClusterPolicy is no longer enforced, how to upgrade

> Kyverno 1.20 stops running ClusterPolicy and other kyverno.io policies. What breaks, how the Helm upgrade gate works and how to migrate first.

Published: 2026-11-12
Author: k8wiz Team
URL: https://k8wiz.com/articles/kyverno-1-20-upgrade-legacy-policies-no-longer-enforced

Kyverno 1.20.0 came out this week (the GitHub release is dated November 12, 2026, after a series of release candidates that began on November 2). Most releases add features and you can upgrade on a quiet afternoon. This one is different. Kyverno 1.20 no longer runs the policy types most clusters have used since the project started: `ClusterPolicy`, `Policy`, `CleanupPolicy`, `ClusterCleanupPolicy` and the `kyverno.io/v2` `PolicyException`.

The objects are not deleted. They stay in etcd, you can still list and export them, and nothing errors at runtime. They simply stop doing anything. A cluster that relied on a `ClusterPolicy` to block privileged pods would accept privileged pods after the upgrade, with no admission error to tell you. This guide covers what changes, how the Helm chart tries to stop you from losing enforcement by accident, and the order of work that gets you to 1.20 with every rule still active.

## What changes in 1.20

The legacy policy engine is gone. The admission, background and reports controllers no longer execute rules from the five `kyverno.io` kinds above. The replacements live in the `policies.kyverno.io` API group, served at `v1`: `ValidatingPolicy`, `MutatingPolicy`, `GeneratingPolicy`, `ImageValidatingPolicy`, `DeletingPolicy`, their namespaced variants, and a new `PolicyException`. The legacy types were deprecated in 1.19, which also shipped the warnings that help you find what is left.

The legacy CRDs remain installed, so `kubectl get cpol -A` still works. Kyverno also denies writes to them. Every create is rejected, and so is almost every update, including a `kubectl apply` of an unchanged manifest and a label-only patch. Deletes always succeed, and you can still strip a finalizer from an object that is already terminating. In 1.19 a re-apply of an unchanged legacy policy was silently accepted, so this is new behavior.

That write denial matters if Argo CD or Flux manages your policies. Every reconcile re-applies the manifest, every apply is denied, and the application shows as permanently out of sync. The chart README describes this as the expected migration signal. There is no setting to bring back the 1.19 behavior. The fix is to remove the legacy manifest from Git once its replacement exists.

The CLI follows the same line. `kyverno apply` and `kyverno test` in 1.20 refuse legacy policy manifests outright. If your CI still tests a `ClusterPolicy`, pin the job to a 1.19 CLI until the policy is migrated.

Not everything in `kyverno.io` is going away. `GlobalContextEntry` and `UpdateRequest` are still supported at `kyverno.io/v2`, and policy reports are untouched.

## Step 1: take an inventory

Start by counting what you have. Run this against every cluster you plan to upgrade, not only the one you think of as typical. Policies created by hand during an incident tend to live in exactly one cluster.

```bash
kubectl get clusterpolicies,policies,cleanuppolicies,clustercleanuppolicies -A
kubectl get policyexceptions.kyverno.io -A
```

The fully qualified name in the second command matters. Kyverno now has two `PolicyException` kinds, so a bare `kubectl get policyexceptions` may resolve to the wrong one.

If both commands return nothing, you can upgrade like any other release. Skip to step 6.

## Step 2: back up the legacy objects

Export everything before you change anything. You will use this file as the source for conversion, and it is your way back if you need to roll back to 1.19.

```bash
kubectl get pol,cpol,cleanpol,ccleanpol,polex -A -o yaml > kyverno-legacy-backup.yaml
```

The short names `cpol`, `pol`, `cleanpol`, `ccleanpol` and `polex` all point to the legacy kinds. The new `policies.kyverno.io` `PolicyException` has no short name, so `polex` is unambiguous here.

## Step 3: convert each rule to a CEL policy

The biggest structural change is that a `ClusterPolicy` held a list of rules that could mix validate, mutate and generate. The new types each do one job. A validate rule becomes a `ValidatingPolicy`, mutate becomes `MutatingPolicy`, generate becomes `GeneratingPolicy`, `verifyImages` becomes `ImageValidatingPolicy`, and a cleanup policy becomes a `DeletingPolicy`. A `ClusterPolicy` with four rules may turn into four policies.

The other change is the language. Patterns, anchors and JMESPath are replaced by CEL expressions. Here is a typical legacy rule that requires two labels on pods.

```yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-labels
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-labels
      match:
        any:
          - resources:
              kinds:
                - Pod
      validate:
        message: "Required labels missing"
        pattern:
          metadata:
            labels:
              app: "?*"
              version: "?*"
```

The same check as a `ValidatingPolicy` looks like this. Matching moves to `matchConstraints`, which uses the same shape as a Kubernetes `ValidatingAdmissionPolicy`. `validationFailureAction: Enforce` becomes `validationActions: [Deny]`.

```yaml
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: require-labels
spec:
  validationActions:
    - Deny
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  validations:
    - expression: >-
        ['app', 'version'].all(label,
          object.metadata.?labels[label].orValue('') != ''
        )
      message: "Required labels missing"
```

The `?` and `orValue('')` pair is worth learning early. Legacy patterns quietly handled missing fields. CEL does not, and an expression like `object.metadata.labels.app` errors on a pod with no labels at all. Optional field access returns a default instead.

A `deny` rule needs its logic inverted, because CEL validations describe what is allowed. A legacy rule that denied when `{{ request.object.spec.replicas }}` was `GreaterThan` 10 becomes an expression that must be true. This version also excludes `kube-system` the way a legacy `exclude` block would, through a namespace selector.

```yaml
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: limit-replicas
spec:
  validationActions:
    - Deny
  matchConstraints:
    resourceRules:
      - apiGroups: ["apps"]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["deployments"]
    namespaceSelector:
      matchExpressions:
        - key: kubernetes.io/metadata.name
          operator: NotIn
          values: ["kube-system"]
  validations:
    - expression: "object.spec.?replicas.orValue(1) <= 10"
      message: "Replica count cannot exceed 10"
```

A few legacy fields have no direct equivalent, and you should know about them before you start. `allowExistingViolations` and `failureActionOverrides` are not supported, and the migration guide points you to policy exceptions instead. `validate.podSecurity` is not supported either, so each Pod Security control becomes its own CEL validation. `patchStrategicMerge` in mutate rules has to be rewritten as `patchType: ApplyConfiguration` or `patchType: JSONPatch`. The `background` setting moves to `spec.evaluation.background.enabled`, and `emitWarning` becomes the `Warn` validation action.

The full field-by-field mapping is in the Kyverno migration guide (kyverno.io/docs/guides/migration-to-cel). Keep it open while you convert. It is long because the old schema was large, but most clusters only use a small part of it.

## Step 4: migrate the exceptions

Legacy exceptions referenced a policy name plus rule names. CEL policies have no named rules, so the new `PolicyException` points at a policy by kind and name and narrows the match with CEL conditions.

```yaml
apiVersion: policies.kyverno.io/v1
kind: PolicyException
metadata:
  name: allow-team-tool
  namespace: delta
spec:
  policyRefs:
    - name: require-labels
      kind: ValidatingPolicy
  matchConditions:
    - name: match-pod-name
      expression: "object.metadata.name.startsWith('important-tool')"
```

Since `kind` is required in each `policyRefs` entry, an exception for a policy you split into several CEL policies needs one entry per new policy. That is easy to miss when one `ClusterPolicy` turned into three.

## Step 5: run old and new side by side on 1.19

Do the switch while you are still on 1.19, where both engines work. Apply each new policy with `validationActions: [Audit]` next to the legacy one it replaces. Both evaluate the same requests, and the new policy writes its results to policy reports without blocking anything.

```bash
kubectl apply -f policies/require-labels-vpol.yaml
kubectl get policyreports -A
```

Compare the results. When the new policy fails the same resources as the old one and nothing else, change it to `Deny` and delete the legacy object. Differences usually come from fields the legacy pattern treated as optional and your CEL expression does not, so look there first.

This is also where you update your CLI tests. In a `kyverno-test.yaml` results entry, a CEL policy has no rule name, and you mark the entry with `isValidatingPolicy: true` (or `isMutatingPolicy`, `isImageValidatingPolicy` and so on) instead.

## Step 6: stop legacy policies coming back

Once a repository is clean, keep it that way. The 1.19 CLI prints a deprecation warning when it loads a legacy policy or exception, and `--warnings-as-errors` turns that warning into a failed run.

```bash
kyverno test . --warnings-as-errors
kyverno apply policies/ --resource samples/ --warnings-as-errors
```

Add the flag to the CI job that tests your policies. A colleague who copies an old `ClusterPolicy` from a blog post will find out in review, not after the upgrade. After you move CI to the 1.20 CLI, the flag is no longer needed for this, because 1.20 rejects legacy manifests anyway.

## Step 7: upgrade with Helm

The 1.20 chart refuses to install or upgrade while legacy policy resources exist on the cluster. It checks in two independent ways. At render time, a Helm `lookup` counts the five legacy kinds and fails with the counts, some resource names and the migration link. Separately, a `pre-install,pre-upgrade` hook Job runs the check server-side for GitOps tools that apply hooks but don't evaluate `lookup` against a live cluster.

If step 1 now returns nothing, the upgrade proceeds normally.

```bash
helm repo update
helm upgrade kyverno kyverno/kyverno -n kyverno
```

If you decide to go ahead with legacy objects still present, for example in a test cluster, you have to say so explicitly.

```bash
helm upgrade kyverno kyverno/kyverno -n kyverno \
  --set upgrade.acknowledgeLegacyPoliciesNotEnforced=true
```

Read the value name carefully. It does not keep anything running. It records that you accept that those policies are no longer enforced. The value was called `upgrade.allowLegacyPolicies` before 1.20, and it was renamed because that name suggested the opposite. A stale `upgrade.allowLegacyPolicies: true` in an old values file now fails the render on purpose.

The gate has known gaps. `helm template`, `helm install --dry-run=client` and `ct lint` never evaluate `lookup`. `--no-hooks`, Argo CD's Skip Hooks option and Flux `disableHooks` skip the Job. Static manifests have neither check. If your pipeline uses any of these, run the step 1 commands yourself before you upgrade.

When the hook Job blocks an install, Helm marks the release `failed`. After you fix the cause, `helm upgrade --install` picks up the failed release. You only need `helm uninstall` if you retry with a plain `helm install` under the same name.

If you install from the YAML manifests instead of Helm, nothing blocks you. Kyverno's init container marks legacy `ClusterPolicy` and `Policy` objects `NotReady` and logs warnings. That is easy to miss in a pipeline, so the inventory in step 1 is your real check.

## Step 8: remove the legacy CRDs

The legacy CRDs carry the `helm.sh/resource-policy: keep` annotation, so Helm leaves them in place even on uninstall. That protects your old objects, and it lets you roll back to a 1.19 release that can still run them.

Once the new policies have run in `Deny` mode for a while and you are sure you won't roll back, delete the CRDs. Deleting a CRD deletes every remaining object of that kind, so check that the backup from step 2 exists first.

```bash
kubectl delete crd \
  clusterpolicies.kyverno.io \
  policies.kyverno.io \
  cleanuppolicies.kyverno.io \
  clustercleanuppolicies.kyverno.io \
  policyexceptions.kyverno.io
```

Leave `globalcontextentries.kyverno.io` and `updaterequests.kyverno.io` alone. Both are still in use.

## How long to budget

For most teams the conversion is the slow part, not the upgrade. A simple label or image-tag rule converts in minutes. Rules with `foreach`, JMESPath context lookups or `patchStrategicMerge` take longer, because you are rewriting logic rather than syntax. Mutate and generate rules deserve the most care, since a mistake there changes objects instead of rejecting them. Sort your inventory by rule type, convert the validate rules first to build confidence with CEL, and plan the 1.20 upgrade only after the inventory comes back empty.
