---
sidebar_label: Expressions
toc_max_heading_level: 3
doc_id: 70a7789e-05c2-431f-9c67-38ec79b46487
description: >-
  Pass data between workflow steps with expressions: reference inputs, step
  outputs, variables, secrets, and per-item data.
keywords:
  - workflows
  - expressions
  - data mapping
  - interpolation
  - nullplatform
---

# Expressions

Expressions are how data moves through a workflow: they take what one step produced and put it where the next step needs it.

## How to write an expression

Expressions live inside `${{ ... }}`. When the whole value is one expression, the result keeps its type: a number stays a number, an array stays an array. When it's embedded in text, the result becomes part of the string:

```yaml
inputs:
  findings: "${{ steps.detect_drift.outputs.findings }}"          # stays an array
  title: "Deprecated AMI on scope ${{ inputs.finding.scope_name }}"  # becomes text
```

Inside an expression you get the usual operators: arithmetic, comparisons, `&&`, `||`, `!`, and the ternary `cond ? a : b`. Use `||` as a fallback: `${{ workflow.inputs.due_days || vars.DUE_DAYS }}` uses the run input when it was provided.

## What you can reference

| Reference | What it is |
|---|---|
| `workflow.inputs.X` | What the trigger or caller passed into the run |
| `steps.X.outputs.Y` | Field `Y` of step X's first output item |
| `steps.X.items` | All of step X's output items |
| `variables.X` | Per-run variables, seeded by `initialValue`, written by `set-variable` |
| `secrets.X` / `vars.X` | [Config entries](/docs/workflows/building-blocks/secrets-and-variables): credentials and settings |
| `execution.id`, `execution.startedAt` | Metadata about the current run, plus `workflowId`, `revision`, `correlationKey`, `organizationId` |
| `signal.payload.X` | The payload that resumed a `signal-wait`, readable by the step right after it |
| `trigger.payload`, `trigger.source` | The raw trigger context, on trigger-started runs |
| `$item`, `$itemIndex`, `$itemsLength` | The current element, its position, and the batch size, when a step processes [items](/docs/workflows/building-blocks/items-and-data-flow) one by one |
| `$items` | The whole input batch, only in a step that takes the batch at once |
| `inputs.X` | The step's **own** `inputs` block, resolved |

The last row is the most common source of confusion: `${{ inputs.X }}` is not `${{ workflow.inputs.X }}`. Bare `inputs` is how a step's `config` reads what its own `inputs` block declared, including `forEach` item variables.

Here is each reference as a real line from published suites like [AMI drift](/docs/tutorials/ami-drift):

```yaml
due_days: "${{ workflow.inputs.due_days || vars.AMI_DRIFT_DUE_DAYS }}"  # run input, falling back to a config entry
rows: "${{ steps.lake_deployments.outputs.rows }}"                      # one field of a step's output
decisions: "${{ steps.decide.items }}"                                  # all of a step's output items
drift_set: "${{ variables.drift_set }}"                                 # a per-run variable
apiKey: "${{ secrets.NP_API_KEY }}"                                     # a secret config entry
category_slug: "${{ vars.AMI_DRIFT_CATEGORY_SLUG || 'engineering' }}"   # a variable config entry, with fallback
correlationKey: "deployagent:${{ execution.id }}"                       # run metadata, namespacing a signal key
description: "${{ $item.description }}"                                 # the item currently being processed
action_item_id: "${{ inputs.id }}"                                      # the step's own declared inputs, read from config
```

## Example: wiring three steps

Three steps: a lake query produces rows, code turns them into findings, and a log message reports the total. Each step declares what it consumes and the expressions do the wiring:

```yaml
- id: detect
  type: module
  plugin_type: code-exec
  inputs:
    rows: "${{ steps.lake_query.outputs.rows }}"   # consume the query's rows
  config:
    code: |
      var rows = inputs.rows || [];                # read them as bare inputs
      var findings = rows.filter(function (r) { return !r.owner; });
      return { findings: findings, total: findings.length };

- id: log_summary
  type: module
  plugin_type: log
  config:
    message: "Scan finished: ${{ steps.detect.outputs.total }} findings."
```

## Built-in functions

Sixteen functions are available inside any expression.

| Function | Returns |
|---|---|
| `len(x)` | Length of a string or array, key count for an object, `0` for null |
| `contains(haystack, needle)` | Substring test on a string, strict-equality membership on an array |
| `concat(a, b, ...)` | One array from any number of arrays. Nulls count as empty, and a non-array argument is wrapped, so `concat(variables.results, $items)` works |
| `default(x, fallback)` | `x` unless it is null or undefined |
| `upper(s)`, `lower(s)`, `trim(s)` | The obvious string operations. Non-strings are stringified first, so `upper(42)` is `"42"` |
| `floor(n)`, `ceil(n)`, `round(n)`, `abs(n)` | The matching `Math` operations |
| `json(x)` | `x` serialized to a JSON string |
| `parseJson(s)` | `s` parsed. Throws on invalid JSON |
| `now()` | The current time, ISO 8601 |
| `uuid()` | A fresh UUID v4 |
| `random()` | A float in `[0, 1)` |

The last three look random, but inside a run they aren't: the engine uses replay-safe versions, so a replayed execution gets the same values it got the first time.

Use `default()` instead of `||` when zero or an empty string are valid values, because `||` treats them as missing.

## What counts as true in a condition

Deciders and connection conditions follow JavaScript rules:

| Falsy | Truthy |
|---|---|
| `null`, `undefined`, `false`, `0`, `NaN`, `""` | **`[]`**, **`{}`**, and everything else |

An empty array counts as true, so a decider that tests an array to ask "did we find anything?" fires on every run. Compare the length instead: `steps.detect.items.length > 0`.

## Common mistakes

- **Deciders and connection conditions take bare expressions**, without `${{ }}`: `expression: "steps.detect.outputs.total > 0"`.
- **A manual input's `default` is form hint text**, not a runtime value. For real defaults, use `||` fallbacks or a variable with `initialValue`.
- **There are no array or object literals.** `contains(["a","b"], x)` won't parse; put the array in a variable's `initialValue` and write `contains(variables.ok_values, x)`.
- **Read other steps through `inputs`.** Declare what a step reads in its `inputs` block instead of referencing `steps.*` from inside `config`.

## Next steps

- [Secrets and variables](/docs/workflows/building-blocks/secrets-and-variables): where `secrets.*` and `vars.*` come from
- [Items and data flow](/docs/workflows/building-blocks/items-and-data-flow): what `$item` and `$items` mean, and when each exists
- [Nodes](/docs/workflows/building-blocks/nodes): the inputs and outputs these expressions wire together
