Conditionals, Ranges and Scope

if, with and range in the forms you will actually write — plus the scope rebinding that causes the most confusing error message in Helm.

intermediate 18 min lesson hands-on task included

Three control structures, and one behaviour shared by two of them that generates a wildly misleading error message. Getting the scope rule right removes most of the frustration people associate with Helm templates.


Topic 1: if, else and Truthiness

{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
{{- end }}

{{- if eq .Values.service.type "LoadBalancer" }}
  loadBalancerIP: {{ .Values.service.loadBalancerIP }}
{{- else if eq .Values.service.type "NodePort" }}
  nodePort: {{ .Values.service.nodePort }}
{{- else }}
  # ClusterIP, nothing extra
{{- end }}

Falsy in Go templates: false, 0, "", an empty list, an empty map, and nil. Everything else is true.

That 0 is a real trap: {{- if .Values.replicaCount }} skips the block when someone deliberately sets zero replicas to scale a workload down. Test explicitly when zero is meaningful:

{{- if not (kindIs "invalid" .Values.replicaCount) }}
replicas: {{ .Values.replicaCount }}
{{- end }}

The comparison and boolean functions are prefix, not infix — this is Go templates, not YAML:

{{- if and .Values.ingress.enabled .Values.ingress.className }}
{{- if or .Values.a .Values.b }}
{{- if not .Values.disabled }}
{{- if eq .Values.env "prod" }}
{{- if ne .Values.replicas 1 }}
{{- if gt (int .Values.replicas) 3 }}
{{- if contains "prod" .Release.Namespace }}
{{- if hasKey .Values "featureFlags" }}
{{- if empty .Values.nodeSelector }}
{{- if regexMatch "^v[0-9]+" .Values.image.tag }}

and and or do not short-circuit in the way you may expect — all arguments are evaluated. So and .Values.tls .Values.tls.secretName still errors when tls is nil. Nest the conditions instead:

{{- if .Values.tls }}
{{-   if .Values.tls.secretName }}
...
{{-   end }}
{{- end }}

Topic 2: with, and the Error It Causes

THE DOT IS THE CURRENT SCOPE — with AND range REBIND IT INSIDE with, THE DOT CHANGES {{- with .Values.ingress }} host: {{ .host }} ↑ .host, not .Values.ingress.host release: {{ .Release.Name }} ↑ BREAKS — .Release is not in this scope release: {{ $.Release.Name }} ↑ $ is always the root context range REBINDS IT PER ITEM {{- range .Values.hosts }} - host: {{ . }} {{- range $k, $v := .Values.labels }} {{ $k }}: {{ $v | quote }} Capturing $k and $v is clearer than the bare dot, and it survives being nested inside another range. with skips the block entirely when the value is empty. THE FIVE BUILT-IN OBJECTS .Values · .Release (Name, Namespace, IsUpgrade, Revision) · .Chart (from Chart.yaml) · .Capabilities (cluster version, API list) · .Files .Capabilities IS HOW A CHART SUPPORTS SEVERAL CLUSTER VERSIONS {{- if .Capabilities.APIVersions.Has "autoscaling/v2" }} … {{- end }}
`with` and `range` rebind the dot, which is why `.Release.Name` stops resolving inside them. `$` is always the root, and it is the fix for the most misleading error message in Helm.

with sets the scope and skips the block entirely if the value is empty — two useful behaviours in one:

{{- with .Values.nodeSelector }}
nodeSelector:
  {{- toYaml . | nindent 2 }}
{{- end }}

If nodeSelector is empty, nothing is emitted — no empty nodeSelector: key to make the manifest invalid. This is the idiomatic way to render optional maps, and it appears in every well-written chart.

The trap: inside the block, . is the value, so the root objects are unreachable.

{{- with .Values.ingress }}
  host: {{ .host }}                    # ✓ .Values.ingress.host
  name: {{ .Release.Name }}            # ✗ nil pointer evaluating interface {}.Name
  name: {{ $.Release.Name }}           # ✓ $ is always the root
{{- end }}

The error message names .Name, which sends people to look at their release name rather than at the scope. Once you know the rule the fix is instant; before you know it, it is a genuinely bad half hour.


Topic 3: range

# a list of scalars
{{- range .Values.hosts }}
  - host: {{ . | quote }}
{{- end }}

# with the index
{{- range $i, $host := .Values.hosts }}
  - name: host-{{ $i }}
    value: {{ $host | quote }}
{{- end }}

# a map — key and value
{{- range $key, $value := .Values.podAnnotations }}
  {{ $key }}: {{ $value | quote }}
{{- end }}

# nested, which is where $ becomes essential
{{- range $host := .Values.ingress.hosts }}
  - host: {{ $host.host | quote }}
    http:
      paths:
      {{- range $path := $host.paths }}
        - path: {{ $path.path }}
          pathType: {{ $path.pathType | default "Prefix" }}
          backend:
            service:
              name: {{ include "checkout.fullname" $ }}    # $ = root, not $host
              port:
                number: {{ $.Values.service.port }}
      {{- end }}
{{- end }}

Capture named variables in a range rather than relying on the bare dot. $host and $path survive nesting; . does not, and a nested range silently rebinds it.

range over an empty list produces nothing, which is usually what you want. When you need a fallback, pair it with if:

{{- if .Values.hosts }}
{{- range .Values.hosts }}
...
{{- end }}
{{- else }}
# no hosts configured
{{- end }}

Two more forms that come up:

{{- range $i := until 3 }}                    # a counted loop, 0 1 2
{{- range $k, $v := .Values.env }}
  - name: {{ $k }}
    value: {{ $v | quote }}
{{- end }}

Note that ranging a map gives you keys in sorted order, deterministically. That matters: a template that produced randomly ordered output would create a diff on every upgrade.


Topic 4: A Complete Optional-Block Example

Putting the three together, the shape most charts need for an Ingress:

{{- if .Values.ingress.enabled -}}
{{- $fullName := include "checkout.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ $fullName }}
  labels:
    {{- include "checkout.labels" . | nindent 4 }}
  {{- with .Values.ingress.annotations }}
  annotations:
    {{- toYaml . | nindent 4 }}
  {{- end }}
spec:
  {{- with .Values.ingress.className }}
  ingressClassName: {{ . }}
  {{- end }}
  {{- if .Values.ingress.tls }}
  tls:
    {{- range .Values.ingress.tls }}
    - hosts:
        {{- range .hosts }}
        - {{ . | quote }}
        {{- end }}
      secretName: {{ .secretName }}
    {{- end }}
  {{- end }}
  rules:
    {{- range .Values.ingress.hosts }}
    - host: {{ .host | quote }}
      http:
        paths:
          {{- range .paths }}
          - path: {{ .path }}
            pathType: {{ .pathType | default "Prefix" }}
            backend:
              service:
                name: {{ $fullName }}
                port:
                  number: {{ $svcPort }}
          {{- end }}
    {{- end }}
{{- end }}

Note the two variables captured at the top. Inside the nested ranges, . is a path object — $fullName and $svcPort are how the values from the root remain reachable without $ littering every line.


Topic 5: Testing All the Branches

A conditional template has more than one output, and rendering it once proves one path works. Keep a values file per shape and render each:

for f in ci/*-values.yaml; do
  echo "== $f"
  helm template checkout . -f "$f" > /dev/null || echo "FAILED: $f"
done
ci/
├── default-values.yaml        everything off
├── ingress-values.yaml        ingress with TLS and several hosts
├── autoscaling-values.yaml    HPA path
├── minimal-values.yaml        only the required keys
└── full-values.yaml           every optional block enabled

The ci/ directory name is a convention chart-testing (ct) recognises — it renders and installs the chart once per values file, which is exactly the coverage a conditional-heavy chart needs. That is covered in the delivery lesson; the directory is worth creating from the start.


Topic 6: When Template Logic Is the Wrong Answer

Charts sometimes accumulate deep conditional nesting — a template with five levels of if is usually a sign that one chart is trying to be several.

The alternatives, in order of how often they are right:

  • Split the chart. A worker chart and an api chart beat one chart with a mode value switching every template.
  • Move the branch into values. Instead of if eq .Values.env "prod" inside templates, put the difference in values/prod.yaml. Environment names in template logic are a smell: it means the chart cannot be used by an environment its author did not anticipate.
  • Use a subchart with a condition for an optional component, rather than wrapping half your templates in one if.

Try it yourself: write the nested range from Topic 3 and reference .Release.Name inside the inner loop without $. Read the error, add $, and watch it work. That is the single most valuable ten seconds in this lesson.

Common mistake: using {{ if .Values.something }} where something is a map that may be empty. An empty map is falsy, so the block is skipped — but a map with one key you did not expect is truthy, and the block renders with values that were never designed for. Prefer an explicit enabled boolean for anything a user turns on and off; it is unambiguous in both the values file and the template.