Opening the engine behind charts: Go Template syntax with {{ }} delimiters, actions and pipelines, {{- -}} whitespace control, if/with/range control structures, built-in functions like default, quote, toYaml, and indent, and how to access the .Values, .Release, .Chart, and .Files objects.

After episode 7, where we built our first chart and saw templates containing {{ .Values.replicaCount }} work like magic — in this episode we dismantle that magic: the Go Template language, the templating language that powers every Helm chart.
Why does this topic matter? Because almost every chart failure you didn't write as a template isn't a Kubernetes failure, but a template syntax and logic failure. Error messages like unexpected "}" in operand or can't evaluate field X in type interface {} will visit every chart author, and the only way to deal with them is understanding the language's rules — not memorizing case-by-case solutions. More than that: template mastery determines how expressive your charts are. An "ordinary" chart only replaces values; a good chart can produce completely different manifests depending on the environment — an Ingress in prod, no Ingress in dev — from just a few lines of template logic. That's the difference between a chart used by one team and a chart used by a hundred teams.
In this episode we'll dissect the foundations of the Go Template language: delimiters and pipelines, whitespace control, the if/with/range control structures, built-in functions for text and YAML manipulation, the concepts of variables and scope, and the objects Helm exposes like .Values, .Release, and .Chart.
Every Helm template is written in two worlds: plain text that becomes output directly, and actions between the {{ and }} delimiters that Go Template evaluates. Plain text is passed through as-is; actions are replaced with the result of their evaluation.
replicas: {{ .Values.replicaCount }}If .Values.replicaCount is 3, the rendered result becomes replicas: 3. Inside an action, there are several forms:
{{ .Values.replicaCount }} or .Values.image.tag with a dotted path.{{ upper "helm" }} produces HELM.|, like a shell pipeline:name: {{ .Values.name | upper }}A pipeline is read from the left: the .Values.name value is sent as the last argument to the upper function. So {{ .Values.name | upper }} equals {{ upper .Values.name }}. Pipelines can be chained: {{ .Values.name | upper | quote }} — becomes upper, then the result is quoted. Pipelines are the main idiom of Helm template writing; almost every production template uses them.
To write a comment inside a template that doesn't get rendered, use {{/* ... */}}:
{{/* Release name is used as the resource name */}}
name: {{ .Release.Name }}Note
The first mistake new template authors make is forgetting that almost everything outside {{ }} becomes output as-is — including spaces, newlines, and indentation. A template that doesn't manage whitespace produces YAML that's "ugly but valid" or not valid at all. For that we need whitespace control, which we cover next.
{{- and -}}Look at the following template:
replicaCount: {{ .Values.replicaCount }}
service:
port: {{ .Values.service.port }}Because each line produces one output line, the rendered result looks tidy. The problem appears when an action sits on a line we want to remove — especially with if and range, where the {{- if ... }} line itself should produce no blank line. To trim whitespace, Go Template provides two dashes: {{- trims whitespace before the action (including the newline), and -}} trims whitespace after the action.
{{- if .Values.ingress.enabled }}
ingress_active: yes
{{- else }}
ingress_active: no
{{- end }}Without {{-, each {{- if ... }}, {{- else }}, and {{- end }} line would leave a blank line in the output, making the YAML invalid or messy. With trimming, the output becomes:
ingress_active: noThe golden rule: all control structures (if, with, range, end) use {{- for left trim, and generally -}} at the end of the line that closes a structure. For end, {{- end }} is common — left trim is enough. This is one of the details that most often separates a tidy chart from an eyesore.
if, with, rangeGo Template has three core control structures, all closed with end.
if / else if / else evaluates a value — empty values (nil, empty string, number 0, false, empty list, empty map) are treated as false:
{{- if .Values.ingress.enabled }}
ingress_active: true
{{- else if .Values.service.loadBalancer }}
loadbalancer_active: true
{{- else }}
clusterip_active: true
{{- end }}with changes scope: inside a with block, the dot . points to the value passed in. This removes repetition of long paths:
{{- with .Values.database }}
host: {{ .host }}
port: {{ .port }}
user: {{ .user }}
{{- end }}If .Values.database is {host: "db.example.com", port: 5432, user: "app"}, then inside the block, .host refers to database.host — not the root. Outside the block, . returns to the root. An important side effect: inside with, .Values is no longer directly accessible, so save the root value to the $ variable if you still need it (covered in the scope section).
range iterates over a list or map, and each iteration shifts the . scope to the current element:
hosts:
{{- range $index, $host := .Values.hosts }}
- {{ $index }}: {{ $host }}
{{- end }}With .Values.hosts: [web.example.com, api.example.com], the result:
hosts:
- 0: web.example.com
- 1: api.example.comFor maps, the syntax is the same but with $key and $value:
labels:
{{- range $key, $value := .Values.labels }}
{{ $key }}: "{{ $value }}"
{{- end }}Warning
Map iteration in Go doesn't guarantee order — range over a map can produce a different result on every render. For manifests where order matters (for example, the order of items within an array), use a list instead, or sorting functions like sortAlpha that we'll cover in episode 9.
Go Template ships a number of built-in functions that are the daily "verbs" of template writing:
default: {{ .Values.name | default "world" }}
quoted: {{ .Values.name | quote }}
upper: {{ "helm" | upper }}
lower: {{ "HELM" | lower }}
title: {{ "helm chart" | title }}
trimmed: {{ " helm " | trim }}
squoted: {{ .Values.name | squote }}With .Values.name: "arman", the result:
default: arman
quoted: "arman"
upper: HELM
lower: helm
title: Helm Chart
trimmed: helm
squoted: 'arman'The most widely used function is default — providing a fallback value when a value is empty. The classic pattern: {{ .Values.image.tag | default .Chart.AppVersion }} — if the user doesn't set tag, fall back to the application version from Chart.yaml. This is a perfect example of the "sensible defaults" we discussed in episode 7. For path prefixes/suffixes, there's trimPrefix and trimSuffix:
stripped: {{ "charts/mychart" | trimPrefix "charts/" }}
version: {{ "v1.27.0" | trimSuffix ".0" }}indent, nindent, toYaml, toJsonThese four functions are the bridge between values data and correctly structured YAML. toYaml converts a Go value (usually a nested map/list) into a YAML block:
env:
{{- toYaml .Values.env | nindent 4 }}With .Values.env: {LOG_LEVEL: info, NODE_ENV: production}, toYaml produces two YAML lines, then nindent 4 prepends a newline and adds 4 spaces of indentation to every line:
env:
LOG_LEVEL: info
NODE_ENV: productionThe difference between indent and nindent: indent N adds N spaces at the start of each existing line, while nindent N adds a newline first then indents — exactly what we need when inserting a block in the middle of YAML. Without nindent, env: and its contents would stick on the same line, producing invalid YAML. For JSON output, use toJson:
json: {{ .Values.env | toJson | quote }}Variables in Go Template are declared with := and always start with $:
{{- $fullName := printf "%s-%s" .Release.Name .Chart.Name }}
name: {{ $fullName }}Scope rules: variables declared inside a block (if, with, range) only live inside that block. Variables declared at the document level are available across the whole template. There's one special variable: $ — which always points to the root context, no matter how deep the scope has shifted. It's a lifesaver when . has been moved by with or range:
{{- with .Values.database }}
host: {{ .host }}
release: {{ $.Release.Name }}
{{- end }}Inside the with block, .host reads database.host, while $.Release.Name can still access the release from the root. The $ pattern is very common in production templates, especially inside range loops in _helpers.tpl.
Tip
Remember the two scope pitfalls that most often confuse people: (1) a $var variable isn't available outside the block where it was declared; (2) with and range shift ., so {{ .Values }} inside a block doesn't work — use $ if you want to get back to the root. Understanding these two rules removes most of the confusion when reading someone else's templates.
Besides .Values, Helm injects several context objects accessible from any template:
| Object | Contents | Example Access |
|---|---|---|
.Values | All merged values (chart defaults + files + --set) | .Values.replicaCount |
.Release | Release info: .Name, .Namespace, .Revision, .IsInstall, .IsUpgrade, .Service | .Release.Name |
.Chart | Metadata from Chart.yaml: .Name, .Version, .AppVersion, .Type | .Chart.AppVersion |
.Capabilities | Cluster capabilities: .KubeVersion, .APIVersions.Has | .Capabilities.KubeVersion.Version |
.Template | Info about the template being rendered: .Name, .BasePath | .Template.Name |
.Files | Contents of non-template files inside the chart | .Files.Get "config/app.conf" |
A real example of .Files — a chart carrying a static configuration file:
data:
app.conf: |
{{ .Files.Get "config/app.conf" | indent 4 }}.Capabilities is often used to adapt manifests to the cluster version, for example conditionally choosing an apps/v1 Deployment for old clusters — details we'll combine with control structures and functions in the following episodes.
Important
Are .Release and .Chart only available when Helm renders — not with helm template? The answer: helm template still fills them in with assumed values (the release name from the argument, metadata from Chart.yaml), so you can debug templates safely without a cluster. This is one of Helm's strengths: every template can be tested locally, without touching Kubernetes at all.
In episode 8 we've dissected the Go Template language that powers every chart: the {{ }} delimiters with actions and | pipelines, the {{- and -}} whitespace control that keeps YAML valid, the if/else control structures, with which shifts scope, and range for iterating lists and maps. We mastered the built-in functions default, quote, squote, upper, lower, title, trim, trimPrefix, trimSuffix, plus the bridges to YAML/JSON: toYaml, toJson, indent, and nindent. We understood $var variables, scope rules, and the magic of $ for returning to the root, plus Helm's objects: .Values, .Release, .Chart, .Capabilities, .Template, and .Files.
Key takeaways:
| pipeline is the main idiom; chain functions from left to right.{{- and -}} keep templates producing clean YAML; use them on all control structures.with and range shift .; use $ to get back to the root.toYaml | nindent N is the standard way to insert nested data into YAML.helm template without a cluster.Now you understand the syntax. In the next episode, episode 9, we add weapons: template functions & helper functions. We'll learn string functions (printf, replace, split, join), type conversion (int, toString, atoi), list functions (list, append, first, last, reverse, uniq), dict functions (dict, hasKey, merge), the Sprig library (date, crypto, encoding, math), and the special Helm functions that distinguish it from ordinary Go Template: include, required, fail, and lookup. See you in episode 9!