Expression syntax

Hull templates are YAML files with ${...} expressions that you interpolate into them. Expressions are resolved first, then the resulting text is parsed as YAML.

This page covers how you write an expression: literals, paths, function calls, and pipelines. For the function catalogue, see Function reference. For $if, $each, $switch, and $include, see Control flow.

The ${...} form

Anything between ${ and } is an expression. The whole token is replaced with the expression’s value at render time.

Template:

metadata:
  name: ${values.name}-config

Values:

name: my-app

Output:

metadata:
  name: my-app-config

When the whole scalar is a single ${...}, the result keeps its native type. A list or map is embedded structurally — it becomes the YAML node, not a string:

Template:

metadata:
  labels: ${values.labels}

Values:

labels:
  app: my-app
  tier: backend

Output:

metadata:
  labels:
    app: my-app
    tier: backend

Embed a map or list directly like this whenever you want it to be the YAML node. Only pipe through toYaml when the destination expects a string that contains YAML — see Output types.

Root namespaces

A path starts with one of four root namespaces (lowercase, no leading dot):

Namespace Holds Example
values merged values map ${values.image.tag}
release release identity ${release.name}
package metadata from hull.yaml ${package.version}
capabilities cluster info ${capabilities.kubeVersion.Version}

A path that doesn’t start with one of these is treated as values.<path>:

${replicas}        # same as ${values.replicas}
${image.tag}       # same as ${values.image.tag}

The explicit form reads better; prefer it.

release keys

Path Meaning
release.name release name (--release-name, else the package name)
release.namespace install namespace (-n; empty under hull template if unset)
release.revision integer revision (1 under hull template)
release.isInstall / release.isUpgrade bool

package keys

Path Meaning
package.name from hull.yaml
package.version from hull.yaml
package.appVersion from hull.yaml

capabilities keys

capabilities.kubeVersion.Version and .GitVersion carry the cluster version; .Major / .Minor are populated only against a live cluster. See Capabilities for the full picture.

Path indexing

A numeric segment indexes into a list. Both arr.0 and arr[0] work.

Values:

ports:
  - { name: http,  number: 80 }
  - { name: https, number: 443 }

Template:

first:  ${values.ports.0.name}
second: ${values.ports[1].number}

Output:

first: http
second: 443

A missing key or an out-of-range index resolves to nil rather than erroring, so default can supply a fallback.

Literals

You use literals as function arguments and in conditions:

Form Examples
String "hello", 'literal'
Integer 0, 42, -7
Float 3.14, -0.5
Boolean true, false
Null null, nil

Prefer single quotes inside expressions — they don’t collide with YAML’s double-quoted scalars.

ok: ${values.tag | default 'latest'}   # → latest
ok: ${"hello" | upper}                 # → HELLO

Pipelines

The | operator chains calls. The value to the left of | becomes the first argument of the function on the right; tokens after the function name are additional literal arguments.

${values.name | upper}          # upper(values.name)          → HELLO
${values.tag  | default 'latest'}  # default(values.tag, 'latest')
${values.replicas | mul 2}      # mul(values.replicas, 2)     → doubled

Multi-step pipelines compose left to right:

${values.name | lower | quote}  # quote(lower(values.name))

Function calls without a pipeline

When every argument is a literal, call a function space-style. The first token after the name is the value the function acts on:

${add 2 3}                # → 5
${printf "%s-%d" "x" 7}   # → x-7
${"hello" | upper}        # → HELLO   (literal piped in)

Arguments are literals, not paths

Every argument after a function name is a literal token (string, number, bool, or null). The engine does not re-evaluate an argument as a path. So a path in argument position is read as a plain string:

${eq values.x "prod"}        # values.x is the literal string "values.x"
${printf "%s" values.name}   # prints the literal "values.name"

There is also no eq / ne function, so a comparison like the first line above silently resolves to nil — see Control flow for the patterns that actually compare values.

To feed a path’s value into a function, put the path on the left of the pipe:

${values.a | mul 2}                # values.a resolved, 2 literal   → correct
${capabilities.kubeVersion.Version | semverCompare '>=1.27.0-0'}

An expression can hold only one path lookup (the pipeline input). To combine two values, compute the result in values.yaml and read the single key.

Truthiness

$if and functions like default, empty, and ternary judge a value’s truthiness:

Value Truthy?
true, non-zero number, non-empty string yes
false, 0, nil (missing key) no
"" no
"false", "False", "FALSE", "0", "no", "No", "NO" no (falsy strings)
non-empty list/map yes
empty list/map no

The falsy-string set matters when a value arrives as text (from --set or an env source): --set flag=false is falsy, not “non-empty string, therefore true”.

Nil handling

Operation Behaviour
values.x.y where y is missing nil
values.x \| upper where values.x is nil ""
values.x \| default 'v' where values.x is nil/empty "v"
add nil 1 error — math rejects nil
empty nil true

Paths and string functions are forgiving; arithmetic and type-strict functions error so a silent wrong value can’t slip through.

Optional fields

A field whose value resolves to nil renders as key: null by default. To make the key disappear when the value is absent, pipe through omitempty:

Template:

data:
  always: fixed
  maybe: ${values.optional | omitempty}

Values: { optional: "" }

Output:

data:
  always: fixed

omitempty drops the key when the value is empty: nil, "", false, 0, or an empty list/map. A field-level $if with no matching branch omits its key the same way — see Control flow.

Escaping ${

To emit a literal ${...} in the output (so hull does not evaluate it), double the dollar sign:

Template:

data:
  template: '$${VAR}'

Output:

data:
  template: ${VAR}

Output types

Return type Output
string inserted into the YAML scalar
number / bool stringified (3, true)
nil empty string
list / map embedded structurally as the YAML node

Because a whole-scalar ${map} embeds structurally, you do not need toYaml to place a map under a key:

Template:

metadata:
  annotations: ${values.podAnnotations}

Values:

podAnnotations:
  prometheus.io/scrape: "true"
  prometheus.io/port: "9100"

Output:

metadata:
  annotations:
    prometheus.io/scrape: "true"
    prometheus.io/port: "9100"

Reach for toYaml | nindent only when the destination expects a string of YAML, such as a ConfigMap data: field. There, toYaml serialises the map to text and nindent indents it as a block scalar:

Template:

data:
  config.yaml: |
    ${values.config | toYaml | nindent 4}

The result is a string containing YAML, not a nested mapping — which is exactly what a data: value must be.

See also