24.4 Named Templates: _helpers.tpl and include vs template
Right, let’s talk about the part of Helm that feels like you’re learning a secret handshake: named templates and the _helpers.tpl file. This is where you stop being a chart user and start being a chart author. You’re not just filling in values; you’re crafting the very logic that generates your Kubernetes manifests. It’s powerful, and honestly, a little bit fun once you get the hang of it.
We keep these reusable code snippets in templates/_helpers.tpl by convention. Helm doesn’t require this filename, but if you name it anything else, every other Helm developer who looks at your chart will immediately know you’re a psychopath. Don’t be that person. The _ prefix is Helm’s cue that these files don’t contain Kubernetes manifests themselves; they’re just a library of templates to be included elsewhere.
Defining a Named Template
A named template is just a chunk of YAML-ish text wrapped in a define directive. Think of it as a function. You give it a name, and it returns a block of text. The name should be dot-delimited and, by overwhelming convention, start with the name of your chart. This is to avoid name collisions when your chart is used as a subchart. If your chart is named my-glorious-api, your template names should look like my-glorious-api.someFunction.
Let’s define a template that creates a standardized label set. We’re sick of typing the same three lines in every Deployment, Service, and PodDisruptionBudget, right?
{{/*
Generate standard labels for our resources. Call it with . as the root context.
*/}}
{{- define "my-glorious-api.labels" }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
The comment inside the {{/* */}} block is a nice touch for documentation. Now, how do we actually use this masterpiece?
The template vs. include Holy War (And Why include Wins)
This is where newcomers get tripped up, because Helm provides two ways to do this, and one is objectively better 99.9% of the time. Let’s look at the old way first.
You could use the template directive. It’s simple, it’s there.
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-api
labels:
{{- template "my-glorious-api.labels" . }}
See that? We just dropped the template right in. It works. But here’s the absurd part: template doesn’t allow for any… post-processing. It just vomits the text directly into your manifest. This becomes a massive problem if your template doesn’t end with a newline (which ours doesn’t, because we used {{- end }} to strip the trailing newline). The YAML structure can break in spectacular and confusing ways.
The modern, correct solution is to use include. Why? Because include is a function that returns the rendered template as a string. This means you can pipe it to other functions before it’s placed in your manifest. This is a game-changer.
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-api
labels:
{{- include "my-glorious-api.labels" . | nindent 4 }}
spec:
template:
metadata:
labels:
{{- include "my-glorious-api.labels" . | nindent 8 }}
Look at that! We render our template with include, and then we pipe it to nindent. The nindent 4 function first adds a newline to the string from include, and then indents every line by 4 spaces. This is the magic that produces perfectly formatted YAML every single time. You cannot do this with template. The designers made a questionable choice by having both, but the community has rightly rallied around include. Consider template deprecated and use include exclusively.
Passing Context (The Dot) is Non-Negotiable
See the dot (.) we passed as the second argument to include? That’s the context—the entire set of variables available in the current scope (like .Chart, .Release, .Values). Your template can’t access any of that data unless you explicitly pass it in. Forgetting the dot is the number one cause of named templates failing silently. The template will render, but all its {{ .Chart.Name }} calls will be blank. You’ll get an empty string and a profound sense of confusion.
If your template only needs a specific value, not the whole context, you can be more precise. Let’s say you have a template that just formats a container name.
{{- define "my-glorious-api.containerName" -}}
{{- .Release.Name }}-{{ .Chart.Name }}-container
{{- end }}
You could call this by passing just the relevant part of the context, but it’s usually simpler and clearer to just pass the whole dot. The key takeaway is: if you need to reference anything from your values or built-in objects, you must pass the context into the template. There is no implicit scope. This is a feature, not a bug; it makes the data flow perfectly clear.