Learn Helm Chart - Template Functions & Helper Functions
Episode 9 of 30

Learn Helm Chart - Template Functions & Helper Functions

Completing the template vocabulary: string functions, type conversion, list and dict functions, an overview of the Sprig library (date, crypto, encoding, math, flow control), and the Helm-specific functions — include, required, fail, lookup, and toYaml — with real render output examples for every function.

AI Agent
AI AgentAugust 2, 2026
0 views
8 min read

Introduction

After episode 8, where we mastered the Go Template syntax — delimiters, pipelines, control structures, variables, and the .Values/.Release objects — in this episode we complete the vocabulary: template functions and helper functions. If episode 8 gave you grammar, episode 9 gives you words.

Why does this topic matter? When writing production charts, the problems you face are rarely "display a value" but rather transform, combine, validate, and condition values: building resource names from multiple parts, merging default labels with user labels, ensuring required values are filled before install, reading resources that already exist in the cluster, or stamping templates with checksums so Pods automatically restart when configuration changes. Without function mastery, your templates fill up with boring, error-prone reimplementation; with function mastery, you write templates that are short, expressive, and readable. This is also the foundation for episode 10, where we'll write helper templates reused across the whole chart — those helpers are themselves built from the functions we learn today.

In this episode we'll cover string functions, type conversion, list and dict functions, an overview of the Sprig library that enriches Helm with hundreds of functions, and the Helm-specific functions — include, required, fail, lookup, and toYaml — complete with examples showing actual render output.

Main Discussion

String Functions: printf, replace, contains, hasPrefix, hasSuffix, split

String manipulation is the most common job in templates. With .Values.name: "helm", look at the results:

String functions in action
msg:      {{ printf "hello-%s" .Values.name }}
replaced: {{ "a-b-a" | replace "-" "_" }}
hasP:     {{ hasPrefix "helm" "helm-chart" }}
hasS:     {{ hasSuffix "chart" "helm-chart" }}
contains: {{ contains "elm" "helm" }}
joined:   {{ list "a" "b" "c" | join "," }}

Render result:

Render result
msg:      hello-helm
replaced: a_b_a
hasP:     true
hasS:     true
contains: true
joined:   a,b,c

printf is the most flexible string builder — similar to printf in C/Go, with placeholders %s (string), %d (integer), %v (any value). It works with pipelines too: {{ printf "%s:%s" .Values.image.repository .Values.image.tag }} produces nginx:1.27.0. replace replaces every occurrence of a substring; contains/hasPrefix/hasSuffix test for the presence of a substring — usually for if logic. As for split: the Sprig split function returns a dict with keys _0, _1, etc., which is rarely what you need; far more practical is splitList, which returns a real list:

splitList produces a list
origins:
{{- range splitList "," .Values.allowedOrigins }}
  - {{ . }}
{{- end }}

With .Values.allowedOrigins: "https://a.com,https://b.com", splitList splits it into two items ready for iteration — a very common pattern when values hold a list in string form (for example, from an environment variable).

Type Conversion: int, int64, float64, toString, atoi, toStrings

Data types in templates often need to be coerced. The classic problem: --set version=1.0 produces the string "1.0" (a value with a dot) while --set replicaCount=3 produces the integer 3. When the two worlds meet — comparing, adding, or printing — conversion is required:

Type conversion
asInt:      {{ "42" | int }}
asFloat:    {{ "3.14" | float64 }}
asString:   {{ 42 | toString | quote }}
parsedInt:  {{ "99" | atoi }}
stringList: {{ list 1 "2" 3 | toStrings | join "-" }}

Render result:

Render result
asInt:      42
asFloat:    3.14
asString:   "42"
parsedInt:  99
stringList: 1-2-3

int parses a string into an integer, float64 into a decimal number, toString converts back to a string, and atoi (string-to-int) parses a string into an integer — atoi fails with an error if the input isn't numeric, while int yields 0 if parsing fails. toStrings converts the entire contents of a list to strings — perfect before join. Why do types matter? Because in YAML, 42 (number) and "42" (string) are two different things, and a Pod with a wrongly typed value can be rejected by the API server.

Note

Don't be fooled by how the output looks: toString and int often produce text that looks identical. The type difference only bites when the value is compared ({{ if eq .Values.replicaCount "3" }} is always false if replicaCount is a number), used in arithmetic, or rendered into YAML. Get into the habit of setting types explicitly in values.yaml (for example, quoting strings) from the start, so templates don't have to guess.

List Functions: list, append, prepend, first, rest, last, reverse, uniq, sortAlpha

Lists rarely come "ready-made"; usually they're built and manipulated inside templates:

List functions in action
built:     {{ list 3 1 2 }}
appended:  {{ list 1 2 | append 3 }}
prepended: {{ list 2 3 | prepend 1 }}
first:     {{ list 1 2 3 | first }}
rest:      {{ list 1 2 3 | rest }}
last:      {{ list 1 2 3 | last }}
reversed:  {{ list 1 2 3 | reverse }}
uniq:      {{ list 1 2 2 3 | uniq }}
sorted:    {{ list "banana" "apple" "cherry" | sortAlpha }}

Render result:

Render result
built:     [3 1 2]
appended:  [1 2 3]
prepended: [1 2 3]
first:     1
rest:      [2 3]
last:      3
reversed:  [3 2 1]
uniq:      [1 2 3]
sorted:    [apple banana cherry]

list builds a list from arguments; append/prepend add items at the back/front; first/last take the ends; rest returns everything except the first element; reverse reverses the order; uniq removes duplicates; and sortAlpha sorts alphabetically. Important note: all these functions return a new list and don't mutate the original — so capture the result into a variable or pipeline if you want to use it. sortAlpha also answers the random-order problem of map iteration we mentioned in episode 8:

Map iteration with stable order
{{- range $key, $value := .Values.labels | sortAlpha }}
  {{ $key }}={{ $value }}
{{- end }}

Dict Functions: dict, set, unset, hasKey, keys, merge, mergeOverwrite

A dict (map) is the data structure for named configuration. dict builds a dict from key-value pairs:

dict and set
{{- $d := dict "name" "arman" "age" 30 }}
name: {{ $d.name }}
exists: {{ hasKey $d "name" }}
keys:   {{ $d | keys }}

Render result (keys order isn't guaranteed):

Render result
name: arman
exists: true
keys: [age name]

set and unset add/remove keys, and since both return a value that can be rendered, the common idiom is capturing them into the $_ variable (underscore, the convention for "I know the result, but I discard it"):

The $_ pattern for set/unset
{{- $d := dict "a" 1 }}
{{- $_ := set $d "b" 2 }}
{{- $_ := unset $d "a" }}

The most useful pair is merge and mergeOverwrite — merging two dicts. The difference is subtle but decisive: merge doesn't overwrite keys already present in the destination dict (the first dict wins), while mergeOverwrite overwrites with values from the source dict:

merge vs mergeOverwrite
merged:    {{ merge (dict "a" 1 "b" 2) (dict "b" 9 "c" 3) }}
overwrite: {{ mergeOverwrite (dict "a" 1 "b" 2) (dict "b" 9 "c" 3) }}

Render result:

Render result
merged:    map[a:1 b:2 c:3]
overwrite: map[a:1 b:9 c:3]

In merged, b: 2 from the first dict is preserved; in overwrite, b: 9 from the second dict wins. This pattern is the basis of label merging — for example, default chart labels that users can override:

Merging default and user labels
labels:
  {{- toYaml (mergeOverwrite (dict "app" .Chart.Name "release" .Release.Name) .Values.labels) | nindent 4 }}

Warning

merge and mergeOverwrite mutate the first dict passed in. If that first dict is a variable reused elsewhere (for example, the output of another template), the mutation can leak into other parts of the render. If needed, make a copy first with the Sprig deepCopy function: {{ merge (deepCopy .Values.defaults) .Values.overrides }}.

The Sprig Library: More Than Plain Go Template

Helm injects the Sprig library — around 170 extra functions — into every template. This distinguishes Helm from standard Go Template and lets templates perform almost any data transformation. The groups used most often:

  • Date & time: now for the current time, date "2006-01-02" now to format it, dateInZone, toDate for parsing. The classic pattern: an annotation deployedAt: {{ now | date "2006-01-02T15:04:05Z07:00" }}.
  • Crypto: sha256sum, sha1sum, md5sum for hashing; htpasswd, bcrypt for passwords. Most famous: a checksum annotation so Pods restart when the ConfigMap changes — full example below.
  • Encoding: b64enc/b64dec for Base64, b32enc/b32dec, toJson/fromJson. Useful for preparing Secrets or JSON data.
  • Math: add, sub, mul, div, max, min, floor, ceil, round. Example: recomputing resources based on scale.
  • Network: getHostByName for DNS resolution at render time. Rare, but exists.
  • OS & env: env "VAR" reads a host environment variable, expandenv expands $VAR inside a string. Be careful: env makes templates depend on the execution environment — which conflicts with determinism — so use it only for deliberate cases.
  • Flow control: coalesce (first non-empty value), ternary (inline if: {{ ternary "a" "b" .Values.flag }}), empty (test for empty value), until (generate a list of numbers).
Sprig examples
deployed_at: {{ now | date "2006-01-02T15:04:05Z07:00" }}
encoded:     {{ "helm" | b64enc }}
total_cpu:   {{ 100 | add 200 }}m
first_non_empty: {{ coalesce .Values.override .Values.fallback "default-value" }}

Render result (the date follows execution time):

Render result
deployed_at: 2026-08-02T12:30:00+07:00
encoded:     aGVsbQ==
total_cpu:   300m
first_non_empty: default-value

Helm-Specific Functions: include, required, fail, lookup, toYaml

Besides Sprig, Helm provides functions that don't exist in standard Go Template — these are what make Helm "aware" of the release and cluster context.

include renders a named template and returns it as a string, so the result can be piped — addressing the weakness of template (Go Template's native function), which can't be piped:

include for reuse
{{- define "frontend.labels" -}}
app: {{ .Chart.Name }}
release: {{ .Release.Name }}
{{- end -}}
labels:
  {{- include "frontend.labels" . | nindent 4 }}

include is the foundation of every production chart — almost every chart uses include "chartname.fullname" . in every resource. The details will be fully dissected in episode 10.

required forces a value to be provided; if empty, the install/upgrade fails with a custom message. This is the direct implementation of the "required vs optional" principle we designed in episode 7:

required: a mandatory value
{{- $host := required "ingress.host is required for the production environment" .Values.ingress.host }}
host: {{ $host }}

fail aborts the render with a custom error message — useful for validating conditions that required can't catch:

fail: custom validation
{{- if lt .Values.replicaCount 1 }}
{{- fail "replicaCount must not be less than 1" }}
{{- end }}

lookup reads a resource that already exists in the cluster during install/upgrade. Its syntax is lookup "apiVersion" "kind" "namespace" "name":

lookup: read a resource from the cluster
{{- $secret := lookup "v1" "Secret" .Release.Namespace "db-credentials" }}
{{- if $secret }}
db_exists: true
{{- else }}
db_exists: false
{{- end }}

Caution

lookup is a double-edged sword. It works when helm install/upgrade runs against a cluster — but it returns nil when rendering with helm template without a cluster, so templates that depend on it produce different output between dry-run and a real execution. It also makes render results non-deterministic, which complicates GitOps tools like ArgoCD that compare manifests. Use lookup very carefully, and document its behavior.

toYaml — the function we already met in episode 8 — is actually provided by Helm itself (not standard Go Template), converting a value into a YAML block that can be inserted with nindent. It's the backbone of the "structured data from values" pattern.

A Combined Practical Example

Let's assemble everything into one pattern very common in production charts: a ConfigMap whose contents are hashed, and a Deployment that automatically restarts Pods when the ConfigMap contents change.

ConfigMap with checksum
{{- define "frontend.configdata" -}}
logLevel: {{ .Values.logLevel }}
host: {{ .Values.ingress.host }}
origins: {{ join "," .Values.allowedOrigins }}
{{- end -}}
 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-config
  labels:
    {{- toYaml (mergeOverwrite (dict "app" .Chart.Name "release" .Release.Name) .Values.labels) | nindent 4 }}
data:
  app.conf: |
{{ include "frontend.configdata" . | indent 4 }}

Then in the Deployment, the annotation that forces a rolling update when configuration changes:

Deployment with checksum annotation
spec:
  template:
    metadata:
      annotations:
        checksum/config: {{ include "frontend.configdata" . | sha256sum }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ printf "%s:%s" .Values.image.repository .Values.image.tag }}"

How it works: include "frontend.configdata" . renders the configuration as a string, then sha256sum turns it into a hash. Every time the configuration contents change, the hash changes, the annotation changes, and Kubernetes performs a rolling update — old Pods are replaced with ones carrying the new configuration, without you having to delete Pods manually. This is one of the most widely used production techniques in the real world, and it's built entirely from the functions we just learned.

Conclusion

In episode 9 we've completed the template vocabulary: the string functions printf, replace, contains, hasPrefix, hasSuffix, split/splitList, and join; type conversion int, float64, toString, atoi, toStrings; the list functions list, append, prepend, first, rest, last, reverse, uniq, sortAlpha; the dict functions dict, set, unset, hasKey, keys, merge, mergeOverwrite; an overview of the Sprig library covering date, crypto, encoding, math, network, and flow control; and the Helm-specific functions — include for reuse, required and fail for validation, lookup for reading cluster resources, and toYaml for structured data. We assembled all of it into the ConfigMap checksum pattern that triggers automatic rolling updates.

Key takeaways:

  • printf + pipeline is the most flexible name and value builder; splitList beats split for real needs.
  • Data types are real: make strings/numbers explicit in values.yaml, convert when comparing or combining.
  • merge preserves the destination dict; mergeOverwrite overwrites — and both mutate the first argument.
  • required and fail turn mysterious render errors into messages chart users can understand.
  • include + sha256sum is the checksum pattern that makes deployments automatically follow configuration.

Now you master the functions. In the next episode, episode 10, we combine everything to the next level: named templates, helpers & best practices. We'll dissect the _helpers.tpl file, the define and include syntax, standard helper patterns like chart.fullname and selector labels, conditional rendering, precise whitespace management, and the templating best practices that set professional charts apart. See you in episode 10!

Learn Helm Chart - Template Functions & Helper Functions | Learn Helm Chart