Helm ships the Sprig function library — around 200 functions. You need about twenty of them, and one structural rule: include rather than template, every time.
Topic 1: The Functions That Earn Their Place
{{ .Values.name | default "app" }} fallback when empty or missing
{{ .Values.name | quote }} wrap in double quotes
{{ .Values.port | int }} force an integer
{{ .Values.name | upper | trunc 63 }} chained, left to right
{{ .Values.name | trimSuffix "-" }}
{{ printf "%s-%s" .Release.Name .Chart.Name }}
{{ toYaml .Values.resources | nindent 4 }} a map → YAML, indented
{{ toJson .Values.config }}
{{ .Values.cert | b64enc }} base64, for Secret data
{{ include "chart.labels" . | nindent 4 }}
{{ required "image.repository is required" .Values.image.repository }}
{{ tpl .Values.rawTemplate . }} render a string AS a template
{{ now | date "2006-01-02" }}
{{ randAlphaNum 16 }}
{{ lookup "v1" "Secret" .Release.Namespace "existing" }}
default deserves care: it triggers on any empty value, not only a missing one — so 0, false and "" all fall through to the default. For a boolean that legitimately may be false, test with if or use hasKey instead:
# WRONG — false becomes true
replicas: {{ .Values.enabled | default true }}
# RIGHT
enabled: {{ if hasKey .Values "enabled" }}{{ .Values.enabled }}{{ else }}true{{ end }}
quote versus explicit quotes matters for YAML types. appVersion: 1.60 is a float; {{ .Chart.AppVersion | quote }} gives you a string. Anything version-like, ID-like or zero-prefixed should be quoted.
randAlphaNum has a trap that costs people their databases. It generates a new value on every render, including every upgrade — so a password generated this way changes on the next helm upgrade while the database still has the old one. The standard fix reuses the existing Secret:
{{- $existing := lookup "v1" "Secret" .Release.Namespace (include "chart.fullname" .) }}
{{- if $existing }}
password: {{ index $existing.data "password" }}
{{- else }}
password: {{ randAlphaNum 24 | b64enc | quote }}
{{- end }}
And lookup has its own limitation: it returns an empty map during helm template and --dry-run, because there is no cluster query. A chart that depends on lookup renders differently in CI than in the cluster, which is a good reason to prefer an external secret manager over generated passwords.
Topic 2: include vs template
{{ template "chart.labels" . }} # OUTPUT — cannot be piped
{{ include "chart.labels" . }} # RETURNS A STRING — can be piped
template is a statement: it writes directly to the output stream. include is a function: it returns a string. Only the second can be piped, which means only the second can be indented:
metadata:
labels:
{{- include "chart.labels" . | nindent 4 }}
Since almost every use of a named template needs indenting, the rule is simply always use include. template appears in older charts and in the generated NOTES.txt, and there is no case where switching to include is worse.
Topic 3: Writing _helpers.tpl
{{/*
Expand the name of the chart.
*/}}
{{- define "checkout.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
A fully qualified app name, bounded by the 63-character label limit.
*/}}
{{- define "checkout.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Common labels. Everything that identifies the release.
*/}}
{{- define "checkout.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{ include "checkout.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels. MUST NOT change between versions — selectors are immutable.
*/}}
{{- define "checkout.selectorLabels" -}}
app.kubernetes.io/name: {{ include "checkout.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
The image reference, defaulting the tag to the chart's appVersion.
*/}}
{{- define "checkout.image" -}}
{{- $tag := .Values.image.tag | default .Chart.AppVersion -}}
{{- printf "%s:%s" (required "image.repository is required" .Values.image.repository) $tag -}}
{{- end }}
Three conventions in there that are worth keeping:
- Namespace every template with the chart name (
checkout.fullname). Subchart helpers share one namespace with the parent’s — two charts both definingfullnamecollide, and the winner is not the one you expect. - Separate
labelsfromselectorLabels, because a Deployment’s selector is immutable and the version-bearing labels change on every release. | replace "+" "_"on the chart label: semver build metadata uses+, which is not a legal label character.
Topic 4: tpl, and Values That Contain Templates
tpl renders a string as a template, with a context you choose. It is how a chart lets users supply template syntax in values:
# values.yaml
ingress:
host: "{{ .Release.Name }}.{{ .Values.domain }}"
host: {{ tpl .Values.ingress.host . | quote }}
Genuinely useful for shared platform charts where every team wants the same pattern with their own release name. Two cautions: tpl is slow when used in a loop over many items, and it renders whatever the user supplied — so a values file becomes executable template code, which is a supply-chain consideration if your values come from somewhere less trusted than Git.
Topic 5: Failing Well
{{ required "image.repository must be set" .Values.image.repository }}
{{- if and .Values.ingress.enabled (not .Values.ingress.className) }}
{{- fail "ingress.className is required when ingress.enabled is true" }}
{{- end }}
{{- if gt (int .Values.replicaCount) 50 }}
{{- fail (printf "replicaCount %d exceeds the supported maximum of 50" (int .Values.replicaCount)) }}
{{- end }}
required guards a single value; fail expresses a rule between values. Both stop rendering with your message, which is worth far more to a user than a Kubernetes error about a field they have never heard of.
Where to draw the line with values.schema.json: the schema handles types, enums, ranges and required keys — declaratively, and editors can read it. fail handles relationships the schema cannot express (“if A is enabled then B is required”). Use both; do not re-implement the schema in template logic.
Topic 6: Library Charts
When several charts in one organisation share helpers, copying _helpers.tpl between them guarantees drift. A library chart is a chart with type: library that contains only define blocks and renders nothing of its own.
# common/Chart.yaml
apiVersion: v2
name: common
type: library
version: 1.2.0
# checkout/Chart.yaml
dependencies:
- name: common
version: 1.2.0
repository: oci://ghcr.io/acme/charts
metadata:
labels:
{{- include "common.labels" . | nindent 4 }}
What it buys: one place to fix the label convention, the security context defaults, or the fullname truncation, for every chart in the organisation. What it costs: a version bump and a helm dependency update in every consumer, so it pays off at roughly five charts and is overhead at two.
Try it yourself: write a helper, use it with template piped into nindent, and read the error. Then switch to include. The failure is immediate and the rule becomes permanent — which is faster than reading it in a style guide.
Common mistake: using randAlphaNum for a generated password without the lookup guard. The first install works. The first upgrade silently changes the Secret while the database still holds the original password, and the application starts failing authentication with no deployment that obviously caused it. Either look up the existing Secret, or take the password from a real secret manager — which is the better answer anyway.