Skip to main content
DevOps Insightsguides & answers

Helm 3 to Helm 4

Charts that rendered correctly for years can render differently under Helm 4 with no error: what changed by design, which regressions are still open, and how to see the difference before your pipeline does.

Helm 4.3.1 and 3.22.1 current · checked 21 October 2026

On this page · 9 sections

The short answer

GitHub’s Ubuntu 26.04 runner image ships Helm 4.3.0, where the 24.04 image had Helm 3.22.0, and ubuntu-latest moves to 26.04 between 19 October and 19 November 2026. A deploy job that never chose a Helm version will start running Helm 4.

Most charts keep working. The changes Helm documents as breaking fail loudly: a post-renderer script, a registry login with a path. The dangerous ones are quiet. Since 4.2.0, null values in values.yaml can be dropped, so a subchart default you deliberately erased comes back, or entries you left empty are not rendered. Those are still open in Helm 4.3.1.

The Helm 3 behaviour on this page was reproduced on Helm 3.18.2. Helm 4 is not installed here, so the Helm 4 behaviour comes from the Helm documentation, the Helm source at v4.3.1, and issue reports with their reproducers. Each item says which.

Which Helm do you have?

Locally, or as a step in the job that deploys:

helm version --short

Compare before you switch

The regressions change rendered manifests, not exit codes, so the reliable check is to render the same chart with the same values through both versions and read the difference. Run it for each environment’s values files.

helm dependency build ./chart
# HELM3 and HELM4 are the paths to the two binaries.
"$HELM3" template my-release ./chart -f values.yaml > rendered-helm3.yaml
"$HELM4" template my-release ./chart -f values.yaml > rendered-helm4.yaml
diff -u rendered-helm3.yaml rendered-helm4.yaml

A difference in securityContext, resources or a missing block is most likely one of the null regressions below. No difference does not cover server-side apply, which only shows against a cluster.

Symptom to change

You seeChange
Rendered manifests suddenly contain values you deliberately erased: runAsUser: 472 or 65534 back in a securityContext, a CPU limit you removed. On OpenShift, where such charts are told to null the UID out, the pods can then be refused for running as a UID the namespace does not allow.A null in the parent chart no longer removes a subchart default
A range over a map in values.yaml skips entries left empty, so some applications, users or hosts are simply not rendered. .Values | toJson no longer shows the key.Keys whose value is null disappear from .Values
The release renders with chart defaults as if your -f file was never passed, with no error. Usually a compact, generated JSON values file.A values file ending on a 4096-byte boundary was silently ignored
helm install or upgrade with --wait (or --rollback-on-failure, which turns waiting on) never finishes: it logs waiting for resource … kind=Rollout expectedStatus=Current actualStatus=Unknown until --timeout, although the Rollout is healthy.--wait hangs until the timeout on custom resources such as Argo Rollouts
Deploy steps that pass a path to a script, often a kustomize wrapper, to --post-renderer fail.--post-renderer takes a plugin name, not a script
A login written with a path after the registry host stops working.helm registry login takes the host name only
Installing a new release where another controller or tool already manages some fields can now fail with a field-ownership conflict, where Helm 3 overwrote them.New releases are installed with server-side apply
Pipelines print a deprecation warning for --atomic or --force.--atomic and --force are renamed
A Grafana gridPos pasted into values loses its y, and --set gridPos.y=9 adds a second key instead of overriding.Not new: keys written y, yes, on, n, no or off become true or false

Regressions: values that silently change

Regression

A null in the parent chart no longer removes a subchart default

Since 4.2.0. Still open in 4.3.1 (21 Oct 2026); fix proposed in #32538, not merged.

Setting a subchart key to null in the parent chart's values.yaml is Helm's documented way to delete a subchart default, and many charts tell you to do it. From 4.2.0 the null is stripped before it reaches the subchart whenever no -f or --set value touches that subchart's scope, so the subchart's default comes back. Sibling keys in the same values.yaml do not change this; only values passed with -f or --set do.

Check

# With your real values files; any difference in securityContext or resources is this.
helm template my-release ./chart -f values.yaml | grep -nE 'runAs|fsGroup|cpu:|memory:'

Fix

  • Pin Helm 4.1.4 or Helm 3 for charts that rely on null overrides until a release includes the fix.
  • Reported workaround: pass any value for that subchart scope with -f or --set, even an unused key (for example --set sub.unused=1), and the nulls take effect again. Two reporters confirmed it on 4.2.0; test it on your chart.
  • Where the subchart allows it, set the value explicitly instead of null, for example an empty securityContext map.

Reproduced here: The old behaviour on Helm 3.18.2: cpu: null under the subchart key removes the subchart's cpu limit.

Regression

Keys whose value is null disappear from .Values

Since 4.2.0. Still open in 4.3.1; fix proposed in #32531, not merged.

A values.yaml entry written as one: with nothing after it, or as one: null, was kept as a key with a null value up to 4.1.4. From 4.2.0 it is removed from .Values altogether. Charts that list things as keys and leave the defaults empty lose those things without an error.

Check

helm template my-release ./chart --show-only templates/YOUR-TEMPLATE.yaml

Fix

  • Give empty entries an empty map instead of nothing: one: {}. The 4.2 cleanup (cleanNilValues in v4.3.1) deletes only null entries, so an empty map should survive; this was read in the source, not run.
  • Or pin Helm 4.1.4 or Helm 3 until the fix ships.

Reproduced here: Helm 3.18.2 keeps the keys: .Values | toJson shows "foobar":null and "baz":null.

Regression

A values file ending on a 4096-byte boundary was silently ignored

Reported on 4.2. Fixed in 4.3.0 (#32525).

When a values file's last line had no trailing newline and the file length was an exact multiple of 4096 bytes, Helm's reader hit end of file before returning that line and treated it as empty input. Small, hand-written YAML files almost never hit it; machine-written single-line JSON does.

Check

wc -c values.json && tail -c1 values.json | od -c   # length, and whether it ends in \n

Fix

  • Use Helm 4.3.0 or later.
  • On an older Helm 4, make sure generated values files end with a newline.

Reproduced here: Helm 3.18.2 loads a 4096-byte single-line JSON values file without a trailing newline correctly.

Regression

--wait hangs until the timeout on custom resources such as Argo Rollouts

Since 4.0.0 (reported on 4.2.4). Open; a maintainer points to --wait=legacy meanwhile. Fix proposed in #32616, not merged.

Helm 4's default wait strategy, watcher, reads readiness with the kstatus library. For kinds it does not know, kstatus expects the Kubernetes conventions: a numeric status.observedGeneration and Ready-style conditions. An Argo Rollout has neither, so its status stays Unknown and Helm waits until the timeout. Helm 3 polled differently and returned.

Check

helm upgrade --install my-release ./chart --wait --timeout 5m --debug 2>&1 | grep 'actualStatus=Unknown'

Fix

  • Use Helm 3's polling for these releases: --wait=legacy. The values in v4.3.1 are watcher (the default for a bare --wait), legacy and hookOnly.
  • If you maintain the custom resource, follow the kstatus conventions (a numeric observedGeneration, a Ready condition), which is what a Helm maintainer recommends long term.

Changed by design

Changed by design

--post-renderer takes a plugin name, not a script

Breaking in Helm 4.0.0, by design.

Post-renderers are plugins in Helm 4. Passing an executable directly no longer works; you pass the name of an installed post-renderer plugin.

Check

grep -rn -- '--post-renderer' .github/ deploy/ 2>/dev/null

Fix

  • Package the post-renderer as a Helm 4 plugin and pass its name.
  • Or stay on Helm 3 for that pipeline while you migrate.

Changed by design

helm registry login takes the host name only

Breaking in Helm 4.0.0, by design.

Helm 4 requires the domain name only, so that login can later be scoped at different levels of a registry.

Check

grep -rnE 'helm registry login [^ ]*/' .github/ deploy/ 2>/dev/null

Fix

  • Log in with the bare host, for example ghcr.io, not ghcr.io/my-org/charts.

Changed by design

New releases are installed with server-side apply

Default from Helm 4.0.0.

helm install defaults to --server-side=true. helm upgrade and rollback default to auto: they keep the method the release already used, so releases created by Helm 3 stay on client-side apply. With server-side apply, the Kubernetes API server tracks which manager owns each field and reports a conflict when a different manager changes one.

Check

# Which managers already own fields on an object the chart creates:
kubectl get deployment NAME -n NAMESPACE -o yaml --show-managed-fields | grep -E 'manager:|operation:'

Fix

  • Resolve the ownership: stop the other manager from setting those fields, or remove them from the chart.
  • To keep the old behaviour for a release, pass --server-side=false explicitly.

Changed by design

--atomic and --force are renamed

Old names still work in Helm 4, with a deprecation warning.

--atomic is now --rollback-on-failure and --force is now --force-replace. For helm template, --hide-notes and --render-subchart-notes are deprecated and do nothing, because template output never includes notes; they go in Helm 5.

Check

grep -rnE 'helm .*(--atomic|--force[^-])' .github/ deploy/ 2>/dev/null

Fix

  • Rename the flags. The old names work in Helm 4, but the warnings clutter logs and Helm 3 does not know the new names, so switch when the pipeline no longer runs Helm 3.

Blamed on Helm 4, but older

Not new in Helm 4

Not new: keys written y, yes, on, n, no or off become true or false

Helm 3 does the same. Open as #32504; a lint warning is proposed in #32505.

Helm parses values as YAML 1.1, where those bare words are booleans, also as keys. y: 4 arrives as "true": 4, so .Values.gridPos.y is empty. --set never goes through the YAML parser, so it writes a real y next to the "true" key. If you only noticed it after moving to Helm 4, the move did not cause it.

Check

helm template my-release ./chart | grep -n '"true"'

Fix

  • Quote such keys in values.yaml: "y": 4.

Reproduced here: On Helm 3.18.2: y: 4 renders as {"true":4}, and --set gridPos.y=9 adds "y":9 beside it.

Pin Helm in GitHub Actions

Choosing the version yourself means the image change cannot choose it for you. With azure/setup-helm:

- uses: azure/setup-helm@v5
  with:
    version: v3.22.1   # or a Helm 4 release you have compared

Teams hit by the null regressions report pinning Helm 4.1.4, the last release before them. Pinning trades one risk for another: an old version stops receiving fixes, so put a date on the pin and revisit it when a Helm release includes the fixes for #32132 and #32530.

Do not “fix” a dropped runAsUser: null by accepting whatever the subchart renders. The default it reinstates may be a UID your platform refuses, or one you removed on purpose.

Where to go next

Sources

Read on 21 October 2026. Helm 3 behaviour reproduced on 3.18.2; Helm 4 behaviour from the documentation, the v4.3.1 source and the linked issues.

Did this get you to an answer?

No text box on purpose — please do not paste production logs anywhere

Something wrong or out of date? Report it