DOC_INDEX
THEME
DOCS/Learn OrchStep/Variables & Outputs

Variables & Outputs

defaults, task and step vars, runtime overrides, and passing data between steps

Two flows of data run through every real workflow: variables flow in (configuration), outputs flow forward (results of steps feeding later steps).

Declaring variables

File-level variables live under defaults: (note: not vars: at the top level). Tasks and steps declare vars: blocks that override them:

name: vars-demo

defaults:
  environment: "dev"
  replicas: "1"

tasks:
  show:
    vars:
      environment: "task-env"     # overrides defaults
    steps:
      - name: print
        vars:
          environment: "step-env" # overrides the task
        func: shell
        do: echo "env={{ vars.environment }} replicas={{ vars.replicas }}"

Everything is referenced the same way: {{ vars.name }}. The engine resolves the nearest definition. Verified precedence, lowest to highest:

defaults  <  env group  <  environment  <  task vars  <  step vars  <  --vars-file  <  --var

Debugging precedence

Seven layers is a lot of power, and eventually a value will surprise you — "why is region set to that?" eval tells you what a value is; --explain tells you why — which layer won, and which it shadowed. (Think git config --show-origin.)

Take this workflow:

name: deploy
defaults:
  region: us-east-1
  replicas: 2
environments:
  production:
    vars:
      region: eu-west-1

Ask "what does each variable resolve to, and from where?":

$ orchstep eval --explain --env production --var replicas=9
VARIABLE  SOURCE       VALUE
region    environment  eu-west-1   (over defaults)
replicas  runtime      9           (over defaults)

region came from the production environment (shadowing the default); replicas came from your --var (shadowing the default). To see the full story for one key, name it — * marks the winner:

$ orchstep eval --explain --env production region
region = eu-west-1   (from environment)
precedence (low -> high; last wins):
   defaults     us-east-1
 * environment  eu-west-1

The same flags a run would use apply (-f, --env, --var, --vars-file), so you're inspecting the exact context a run would see — without executing anything.

Environment variables too

--explain also reports the OS environment your workflow's top-level env: sets (values masked; a {{ secrets.X }} shows as a placeholder and is never resolved), flagging when it overrides an inherited OS variable:

# OS environment set by the workflow's env: (masked; task/step env: not shown)
ENV VAR     SOURCE         VALUE
API_TOKEN   workflow env:  ***
AWS_REGION  workflow env:  us-east-1
HOME        workflow env:  /custom/home   (over inherited (os))

See the CLI reference for the full flag. --explain inspects the context before a run; to inspect live state partway through a run, set a breakpoint on a step (flags: [breakpoint], or --break-before <step> with no file edit).

Runtime overrides

orchstep run show --var environment=qa --var replicas=3
orchstep run show --vars-file overrides.yml     # YAML map of key: value

--var beats everything, including --vars-file.

Value formats: strings vs structured data

How a variable is typed depends on where it comes from:

SourceMultiline textJSON / object value
defaults: / task & step vars:yes — YAML block scalars (|)yes — nested maps/lists, kept as objects
--vars-file file.ymlyes (YAML block scalars)yes (native, nested)
stdin (… | orchstep run --stdin-var X)yes (raw text)yes — auto-detected (JSON, then YAML)
--var key=valueawkward (the shell must pass a literal newline)no — always a plain string

The one that surprises people: --var values are always strings. --var cfg='{"port":8080}' gives you the literal string {"port":8080}, not an object — {{ vars.cfg.port }} won't work until you parse it (see below).

Multiline and structured values

Declare them in YAML — block scalars for text, nesting for objects:

defaults:
  banner: |                 # multiline string (newlines preserved)
    Deploying service
    Please wait...
  db:                        # nested object
    host: db.internal
    port: 5432
    replicas: [a, b, c]

Read them as {{ vars.banner }}, {{ vars.db.host }}, {{ index vars.db.replicas 0 }}.

For data coming from another program, pipe JSON to stdin — it's parsed automatically (no flag needed beyond --stdin-var):

echo '{"user":"ada","roles":["admin"]}' | orchstep run grant --stdin-var input
# then: {{ vars.input.user }}  ->  ada     (also available as {{ stdin.user }})

Converting between text and objects

You haveYou wantTemplate
objectJSON string{{ vars.obj | toJson }} (or toPrettyJson)
objectYAML string{{ vars.obj | toYaml }}
JSON/YAML stringobject{{ toObj vars.s }} or {{ vars.s | fromJson }}

Two rules of thumb:

  • Pick one type per variable (string or object) and convert only at the boundary: parse a string into an object once with toObj, and serialize an object back to a string only when something needs text (toJson). So if you're handed a JSON string via --var, normalize it early — e.g. a step var cfg: "{{ toObj vars.raw }}" — then use {{ vars.cfg.field }} everywhere after.
  • Shells only receive strings. To pass an object to an external CLI, serialize it — do: "mytool --config '{{ vars.db | toJson }}'" — or pass individual fields like {{ vars.db.host }}. A subprocess can't see a nested object directly.

Step outputs

A step exposes results with an outputs: block. Later steps read them as steps.<step_name>.<output_name>:

tasks:
  main:
    steps:
      - name: build_image
        func: shell
        do: echo "Built image myapp:v1.2.3"
        outputs:
          tag: '{{ result.output | regexFind "myapp:([a-z0-9.]+)" }}'

      - name: deploy
        func: shell
        do: echo "deploying {{ steps.build_image.tag }}"

result is the just-finished execution: result.output (stdout), result.exit_code, and - when the output parses as JSON or YAML - result.data_object for direct field access:

- name: emit_json
  func: shell
  do: |
    echo '{"orders": 124, "region": "apac"}'
  outputs:
    orders: "{{ result.data_object.orders }}"

regexFind

The workhorse extractor. With a capture group it returns the group; without one it returns the whole match:

outputs:
  version: '{{ result.output | regexFind "myapp:([a-z0-9.]+)" }}'   # -> v1.2.3
  full:    '{{ result.output | regexFind "myapp:[a-z0-9.]+" }}'     # -> myapp:v1.2.3

Use explicit character classes like [a-z0-9.]+ in patterns - backslash-escapes such as \w are invalid inside Go template strings.

Where to go next

Branching - make steps conditional on those variables and outputs.