Flow Control: if, range, with

Use conditional logic, loops, and scoping in Helm templates. Build smart charts that adapt to different configurations.

8 min read

Flow Control: if, range, with

In the previous tutorial, we learned how functions transform values. But what about decisions? What if you want to include an Ingress only when ingress.enabled is true? Or loop over a list of environment variables? That's flow control.

if / else — Conditional Rendering

The most common construct. It determines whether a block of YAML gets included:

{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ .Release.Name }}-ingress
spec:
  rules:
    - host: {{ .Values.ingress.host }}
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: {{ .Release.Name }}-svc
                port:
                  number: 80
{{- end }}

If ingress.enabled is false (or missing), the entire Ingress resource is omitted. Not commented out — completely gone from the rendered output.

if / else if / else

{{- if eq .Values.environment "production" }}
replicas: 3
{{- else if eq .Values.environment "staging" }}
replicas: 2
{{- else }}
replicas: 1
{{- end }}

What Counts as "Falsy"?

In Go templates, these values evaluate to false:

  • false (boolean)
  • 0 (number)
  • "" (empty string)
  • nil / null
  • Empty collections (empty list, empty map)

Everything else is truthy. This means you can do:

# Check if a value exists and is non-empty
{{- if .Values.customAnnotations }}
annotations:
  {{- toYaml .Values.customAnnotations | nindent 4 }}
{{- end }}

Comparison and Logical Operators

Go templates use function syntax for comparisons — no ==, !=, &&, || symbols:

# Equality
{{ if eq .Values.env "production" }}...{{ end }}

# Not equal
{{ if ne .Values.env "development" }}...{{ end }}

# Greater than / less than
{{ if gt .Values.replicas 1.0 }}...{{ end }}
{{ if lt .Values.replicas 10.0 }}...{{ end }}
{{ if ge .Values.replicas 1.0 }}...{{ end }}
{{ if le .Values.replicas 10.0 }}...{{ end }}

# Logical AND
{{ if and .Values.ingress.enabled .Values.ingress.host }}...{{ end }}

# Logical OR
{{ if or .Values.nodePort .Values.loadBalancer }}...{{ end }}

# NOT
{{ if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}

"Why 1.0 instead of 1 in comparisons?"

Go templates are strict about types. If .Values.replicas is an integer (from YAML), comparing with gt .Values.replicas 1 works. But if there's any type mismatch, using 1.0 (float) is safer since YAML numbers can be parsed as either. In practice, most people just use the integer form and it works fine.

Combining Conditions

# AND — both must be true
{{- if and .Values.ingress.enabled (ne .Values.service.type "NodePort") }}
# Create Ingress only if enabled AND not using NodePort
{{- end }}

# OR with nesting
{{- if or (eq .Values.env "production") (eq .Values.env "staging") }}
# Enable monitoring for prod and staging
{{- end }}

# Complex conditions
{{- if and .Values.redis.enabled (or (eq .Values.env "production") .Values.redis.forceEnable) }}
# Deploy Redis
{{- end }}

range — Looping

range iterates over lists and maps. It's how you generate repeated YAML blocks:

Iterating Over Lists

# values.yaml
# env:
#   - name: DATABASE_URL
#     value: "postgres://db:5432"
#   - name: REDIS_URL
#     value: "redis://cache:6379"
#   - name: LOG_LEVEL
#     value: "info"

containers:
  - name: app
    env:
      {{- range .Values.env }}
      - name: {{ .name }}
        value: {{ .value | quote }}
      {{- end }}

Inside the range block, the dot (.) is rebound to the current item. So .name and .value refer to each list item's fields, not the root object.

Iterating Over Maps

# values.yaml
# labels:
#   team: backend
#   cost-center: engineering
#   app: my-service

metadata:
  labels:
    {{- range $key, $value := .Values.labels }}
    {{ $key }}: {{ $value | quote }}
    {{- end }}

Result:

metadata:
  labels:
    app: "my-service"
    cost-center: "engineering"
    team: "backend"

The Dot Problem (and How to Fix It)

This is a classic pitfall. Inside range, the dot no longer points to your root data:

# BROKEN — .Release.Name doesn't exist inside range
{{- range .Values.containers }}
- name: {{ .name }}
  image: {{ .image }}
  labels:
    release: {{ .Release.Name }}   # ERROR!
{{- end }}

Two fixes:

# Fix 1: Save root context in a variable
{{- $root := . -}}
{{- range .Values.containers }}
- name: {{ .name }}
  image: {{ .image }}
  labels:
    release: {{ $root.Release.Name }}
{{- end }}

# Fix 2: Use $ (always refers to root scope)
{{- range .Values.containers }}
- name: {{ .name }}
  image: {{ .image }}
  labels:
    release: {{ $.Release.Name }}
{{- end }}

The $ shorthand is cleaner. Use it.

Generating Unique Resources from a List

# values.yaml
# databases:
#   - name: users
#     size: 10Gi
#   - name: orders
#     size: 20Gi
#   - name: analytics
#     size: 50Gi

{{- range .Values.databases }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: {{ $.Release.Name }}-{{ .name }}-pvc
spec:
  accessModes: ["ReadWriteOnce"]
  resources:
    requests:
      storage: {{ .size }}
{{- end }}

This generates three separate PVCs from a single template. The --- separator creates distinct YAML documents.

range with Index

{{- range $index, $host := .Values.ingress.hosts }}
  # Host {{ $index }}: {{ $host }}
{{- end }}

Empty List Handling

{{- if .Values.extraVolumes }}
volumes:
  {{- range .Values.extraVolumes }}
  - name: {{ .name }}
    {{- if .configMap }}
    configMap:
      name: {{ .configMap.name }}
    {{- else if .secret }}
    secret:
      secretName: {{ .secret.name }}
    {{- end }}
  {{- end }}
{{- end }}

with — Scoped Blocks

with is like if + scope change in one. It checks if a value exists and narrows the dot to that value:

# Without "with" — lots of repetition:
{{- if .Values.serviceAccount }}
{{- if .Values.serviceAccount.annotations }}
annotations:
  {{- range $key, $value := .Values.serviceAccount.annotations }}
  {{ $key }}: {{ $value | quote }}
  {{- end }}
{{- end }}
{{- end }}

# With "with" — cleaner:
{{- with .Values.serviceAccount }}
{{- with .annotations }}
annotations:
  {{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

Inside a with block, the dot (.) refers to the scoped value. So in with .Values.serviceAccount, the dot is the serviceAccount object.

with + else

{{- with .Values.tolerations }}
tolerations:
  {{- toYaml . | nindent 2 }}
{{- else }}
tolerations: []
{{- end }}

Common with Pattern

The canonical use case — optional sections in a Pod spec:

spec:
  containers:
    - name: {{ .Chart.Name }}
      image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
      {{- with .Values.resources }}
      resources:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.livenessProbe }}
      livenessProbe:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.readinessProbe }}
      readinessProbe:
        {{- toYaml . | nindent 8 }}
      {{- end }}
  {{- with .Values.nodeSelector }}
  nodeSelector:
    {{- toYaml . | nindent 4 }}
  {{- end }}
  {{- with .Values.tolerations }}
  tolerations:
    {{- toYaml . | nindent 4 }}
  {{- end }}
  {{- with .Values.affinity }}
  affinity:
    {{- toYaml . | nindent 4 }}
  {{- end }}

This pattern means: if the user doesn't set these values, the keys are completely omitted from the manifest (not set to empty/null).

Whitespace Cheat Sheet

Flow control and whitespace are best friends. Here's the definitive guide:

# BAD — extra blank lines
metadata:
  labels:
    {{ if .Values.team }}
    team: {{ .Values.team }}
    {{ end }}
    app: my-app

# GOOD — trimmed with dashes
metadata:
  labels:
    {{- if .Values.team }}
    team: {{ .Values.team }}
    {{- end }}
    app: my-app

Rules of thumb:

  1. Always use {{- on if, else, end, range, with
  2. Don't use -}} unless you specifically need to eat the newline after a block start
  3. Never use {{- on lines that output values — you'll eat indentation

Putting It All Together

Here's a real-world deployment template using all the concepts:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "my-chart.fullname" . }}
  labels:
    {{- include "my-chart.labels" . | nindent 4 }}
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
  selector:
    matchLabels:
      {{- include "my-chart.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "my-chart.selectorLabels" . | nindent 8 }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            {{- range .Values.ports }}
            - name: {{ .name }}
              containerPort: {{ .port }}
            {{- end }}
          {{- with .Values.env }}
          env:
            {{- range $key, $value := . }}
            - name: {{ $key }}
              value: {{ $value | quote }}
            {{- end }}
          {{- end }}
          {{- with .Values.resources }}
          resources:
            {{- toYaml . | nindent 12 }}
          {{- end }}
      {{- with .Values.nodeSelector }}
      nodeSelector:
        {{- toYaml . | nindent 8 }}
      {{- end }}

What's Next?

You can now make decisions, loop over data, and scope values. But you're probably noticing some repeated patterns (like the name generation, label blocks, etc.).

In the next tutorial, we'll learn about named templates and helpers — how to extract reusable template snippets into _helpers.tpl and keep your charts DRY.