Macros
A macro is a template string that generates a file path by substituting named variables. Macros are used in situation templates and directory definitions.
Note
This page is about file-path macros used by the project system. For named values you create and read inside a workflow — including {name} substitution in text parameters — see Workflow Variables.
Before diving into the full syntax, here are two examples that show what macros look like in practice:
Template: {outputs}/{node_name?:_}{file_name_base}{_index?:03}.{file_extension}
With all variables:
outputs="outputs", node_name="ImageGen", file_name_base="render", _index=2, file_extension="png"
→ outputs/ImageGen_render002.png
With optional variables omitted:
outputs="outputs", file_name_base="render", file_extension="png"
→ outputs/render.png
{outputs} is a directory name that the project system supplies automatically. {node_name?:_} is optional — when present, its value is followed by _; when absent, the block disappears entirely. {_index?:03} is optional and zero-padded to three digits when present.
Variable syntax reference
Required variable
{variable_name}
The variable must be provided. If it is missing when the macro is resolved, resolution fails with an error.
Optional variable
{variable_name?}
The ? marks the variable as optional. If the variable is not provided, the {} block — and any format spec — is omitted entirely from the output. The rest of the macro continues normally.
Trailing form. The ? can also appear at the end of the last format spec — {shot:upper?} is equivalent to {shot?:upper}. Both spellings mark the variable optional. Applies to plain variables and sequence shorthand alike: {###:upper?} is the same as {###?:upper}. Quote the separator ({shot:'lower?'}) to keep the ? as a literal character.
Separator format
{variable_name:separator}
Appends separator after the variable's value. Any text that is not a recognized keyword (see transformations below) and not a numeric padding is treated as a separator.
This is most useful for building path prefixes that disappear cleanly when the variable is absent. For example, {node_name?:_} adds node_name_ before a filename when the node name is known, but produces nothing at all when it isn't:
{node_name?:_}{file_name_base}
node_name="ImageGen", file_name_base="render" → ImageGen_render
node_name not provided, file_name_base="render" → render
Path separators work the same way — {sub_dirs?:/} adds a subdirectory prefix only when sub-directories are specified:
{outputs}/{sub_dirs?:/}{file_name_base}.{file_extension}
sub_dirs="lighting/pass_a", file_name_base="render", file_extension="exr"
→ outputs/lighting/pass_a/render.exr
sub_dirs not provided, file_name_base="render", file_extension="exr"
→ outputs/render.exr
Leading separator
{variable_name:^prefix}
Mirror of the separator format, but the text is prepended to the variable's value rather than appended. Marked by a ^ at the start of the format-spec text; anything after the ^ is the literal prefix payload. Renders iff the variable emits — an unbound optional variable takes its leading separator with it into oblivion.
{file_name_base}{version?:^_v}.{file_extension}
file_name_base="render", version=3, file_extension="png" → render_v3.png
file_name_base="render", version not provided → render.png
The load-bearing pattern: pair with a sequence slot to get a version suffix that comes and goes with the sequence:
render{###?:^_v}.png
first save (slot omitted) → render.png
second save (slot fires) → render_v001.png
third save → render_v002.png
Works outside filenames too — the trailing separator is not just for path prefixes and neither is the leading one:
Hello, {name?}!{intro?:^ Nice to meet you.}
name="Alice", intro="y" → Hello, Alice! Nice to meet you.y
name="Alice", intro absent → Hello, Alice!
name absent, intro absent → Hello, !
Composition rules
- A variable can carry at most one leading separator.
- The leading separator is applied after every other format spec on the same variable, regardless of where you write it in the template.
{shot:03:^_v}and{shot:^_v:03}both rendershot=5as_v005— the parser normalizes the leading spec to the tail of the list so ordering never mangles the prefix.
Related grammar errors
| Error | Cause |
|---|---|
EMPTY_LEADING_SEPARATOR |
:^ with no payload after the caret |
MULTIPLE_LEADING_SEPARATORS |
Two :^-marked specs on the same variable |
Limitation. A ^ at the start of a format spec is now reserved as the leading-separator discriminator, so a literal ^_v prefix isn't spellable today. If a real use case surfaces, a future escape mechanism (e.g. \^ or a '^'-quoted form) will fill it in.
Numeric padding
{variable_name:03}
Zero-pads the value to the specified width. The variable must hold an integer value.
{_index:03} with _index = 5 → "005"
{_index:04} with _index = 12 → "0012"
Used for auto-incrementing filenames under the create_new collision policy. Numeric padding (:NN) on a single unresolved variable is the opt-in: the first save lands at index 1 (or omitted, for the optional form), and subsequent saves walk forward against the same template — the padding format is preserved across the whole sequence.
- Optional form
{_index?:03}— absent on the first save, then_001,_002, … on collision (padded width preserved). - Required form
{_index:03}— present from the first save:_001,_002,_003, … with consistent zero-padded width across the whole sequence.
Template: {file_name_base}_v{_index:03}.{file_extension}
Save #1 → render_v001.png
Save #2 → render_v002.png
Save #3 → render_v003.png
The variable name does not need to be _index; any single unresolved required variable with :NN padding will be auto-allocated. Without padding, an unresolved required variable is treated as a missing binding (a configuration error) and the save fails — this prevents {shot} from silently being filled with 1, 2, 3, … when the user forgot to wire it up.
Sequence slot ({###})
{#} → 1-digit minimum (1, 2, ..., 9, 10, 11, ...)
{###} → 3-digit minimum (001, 002, ..., 999, 1000, ...)
{####} → 4-digit minimum (0001, 0002, ..., 9999, 10000, ...)
{##?} → 2-digit minimum, optional (omitted on first save; 01, 02, … on collision)
A run of # characters inside {} braces is the explicit syntax for a sequence slot. Each # contributes one digit to the minimum render width. Values below 10 ^ width are zero-padded to that width; values at or above it render at their natural width (no truncation). This matches the universal ### convention from ffmpeg (%03d), Houdini ($F4), Nuke (####), and Python's :03 format spec.
A trailing ? inside the braces (e.g. {##?}) marks the slot optional — the same rule as for any other variable. Optional slots are omitted on the first save and only fill in on collision.
Template: {file_name_base}_v{###}.{file_extension}
Save #1 → render_v001.png
Save #2 → render_v002.png
...
Save #999 → render_v999.png
Save #1000 → render_v1000.png (overflow: 4 digits, not truncated)
Template (optional): {file_name_base}{##?}.{file_extension}
Save #1 → render.png (slot omitted)
Save #2 → render01.png (slot fills in on collision)
Save #3 → render02.png
Use {###} whenever you want a system-allocated sequence index. It says "this slot is what create_new should advance on collision" without leaning on the numeric-padding heuristic described above, so a macro author who genuinely needs a user-bound {shot:03} variable can write that without ambiguity.
Why the {} wrapping. Macro templates often appear in places where bare # chars have other meanings — Markdown headers, comments, shell scripts. Wrapping the sigil inside {} keeps the sequence-slot syntax inside the same delimiters that already mark "this is a macro variable," so authors don't need escaping rules for stray # chars in static text.
One sequence slot per macro. A template with two {###} blocks (e.g. {###}_take_{##}.png) is rejected at parse time — the system has no way to know which slot to auto-allocate. Compose the second number as an explicit {var} if you need it.
Relationship to {_index:NN}. Internally {###} desugars to a variable named _index carrying a sequence-format marker. The legacy {_index:03} / {_index?:03} syntax continues to work and is still treated as a sequence slot for backward compatibility, but {###} is the recommended form going forward. Future versions may retire the {_index:NN} shorthand once project templates have migrated; see issue #4902.
Unresolved sequence slots
A required {###} slot has no value until the write path allocates one. Any code that resolves a macro before that allocation happens — a node previewing where its output will land, a UI classifying user input as absolute-vs-relative — has to tell the resolver what to do about the empty slot. GetPathForMacroRequest exposes the choice as unresolved_sequence_slot_behavior, whose values live in the UnresolvedSequenceSlotBehavior enum:
| Behavior | Renders as | When to use |
|---|---|---|
FAIL (default) |
MISSING_REQUIRED_VARIABLES failure |
The write path — the failure is the signal on_write_file_request uses to seed the first index and retry on collision. Nothing else should override the default. |
RENDER_SEQUENCE_PATTERN |
### (or ####, matching source width) |
Presentation only. Renders the slot as its bare hash glyphs (the universal ffmpeg / Houdini / Nuke convention) so the resulting path reads as its on-disk shape. Never open, write, or hand this string to any I/O primitive — the pattern is not a valid filesystem path. |
START_AT_ZERO |
000 |
Previewing 0-indexed sequences before the first save. |
START_AT_ONE |
001 |
Previewing "what would my first save land at" — matches the write-path seed, so the preview lines up with the real save when the destination is empty. |
Optional slots ({###?}) are unaffected — they're already omitted when unbound, so the flag only takes effect on required slots.
Rule of thumb. If your code is about to open a file, do not pass a flag; let the write path do its thing. If your code is about to show a string to a user, use RENDER_SEQUENCE_PATTERN. START_AT_ZERO / START_AT_ONE are narrow tools for previewing an actual first save.
String transformations
| Format spec | Description | Example result |
|---|---|---|
:lower |
All lowercase | "my autumn shoot" |
:upper |
All uppercase | "MY AUTUMN SHOOT" |
:title |
Title Case | "My Autumn Shoot" |
:snake |
snake_case | "my_autumn_shoot" |
:pascal |
PascalCase | "MyAutumnShoot" |
:camel |
camelCase | "myAutumnShoot" |
:screaming_snake |
SCREAMING_SNAKE_CASE | "MY_AUTUMN_SHOOT" |
:slug |
Slug (spaces→hyphens, non-alphanumeric stripped) | "my-autumn-shoot" |
:dot |
dot.case | "my.autumn.shoot" |
:abbrev |
First letter of each word | "MAS" |
:trim |
Strip leading/trailing whitespace | "My Autumn Shoot" |
For example, if workflow_name is "My Autumn Shoot":
{workflow_name:lower} → "my autumn shoot"
{workflow_name:upper} → "MY AUTUMN SHOOT"
{workflow_name:title} → "My Autumn Shoot"
{workflow_name:snake} → "my_autumn_shoot"
{workflow_name:pascal} → "MyAutumnShoot"
{workflow_name:camel} → "myAutumnShoot"
{workflow_name:screaming_snake} → "MY_AUTUMN_SHOOT"
{workflow_name:slug} → "my-autumn-shoot"
{workflow_name:dot} → "my.autumn.shoot"
{workflow_name:abbrev} → "MAS"
:snake, :pascal, :camel, :dot, and :screaming_snake also handle camelCase and PascalCase input correctly by splitting on case transitions, so {varName:snake} → "var_name" works as expected.
:trim is most useful as a pre-processing step before another transformation, e.g. {name:trim:snake} strips surrounding whitespace and then converts to snake_case.
Default value
{variable_name|default_value}
If the variable is not provided, default_value is used instead.
{workflow_name|untitled} → uses "untitled" if workflow_name is not provided
Chaining format specs
Multiple format specs are separated by : and applied left to right. If a separator is used, it must come first:
{variable_name:_:lower} → lowercase value with underscore appended
{variable_name:lower:slug} → lowercase, then slug
Quoted separators
If your separator text matches a keyword like lower or upper, wrap it in single quotes to treat it as a literal separator:
{variable_name:'lower'} → appends the text "lower" as a separator
Resolution
When a macro is resolved, directory names and builtin variables are supplied automatically by the project system. You only need to provide the variables specific to your operation (like file_name_base and file_extension).
For example, resolving the save_node_output situation macro:
Template: {outputs}/{sub_dirs?:/}{node_name?:_}{file_name_base}{_index?:03}.{file_extension}
Automatic: outputs → resolved from the "outputs" directory definition → "outputs"
Provided: node_name="StyleTransfer", file_name_base="portrait", _index=3, file_extension="png"
Result: outputs/StyleTransfer_portrait003.png
Directory names (like outputs) are automatically resolved to their configured paths. See Directories.
Builtin variables (like workflow_name, project_dir) are also supplied automatically. See Environment & Builtin Variables.
Reverse matching
The macro system can also work in reverse: given an actual path and a macro template, it can extract the values of the variables. This is used when the system needs to identify whether a file belongs to a known project directory and what metadata is encoded in its name — e.g. the create_versioned_workflow save path re-reads an existing file's name to figure out which _index to bump.
The public API is ParsedMacro.extract_variables(path, known_variables, secrets_manager) (or matches(...) for a boolean check).
Basic example:
Template: {outputs}/{node_name?:_}{file_name_base}{_index?:03}.{file_extension}
Path: outputs/StyleTransfer_portrait003.png
Extracted: outputs="outputs", node_name="StyleTransfer", file_name_base="portrait", _index=3, file_extension="png"
How extraction decides where each variable ends
For a template like {a}/{b}/{c}.{ext} the extractor walks left to right. Each variable's value ends at the next anchor — a fixed piece of text that must appear in the path. Two kinds of anchors exist:
- Next-segment anchor. The text of the following static segment (e.g.
.png), or the leading-separator prefix of the following variable (e.g._vinside{###?:^_v}). - Self-anchor. The variable's own trailing separator (e.g. the
_in{node_name?:_}).
The extractor uses whichever produces the tightest, self-consistent split. When both are available, the search direction depends on what follows: if the next segment is a static, take the LATEST self-anchor before the static (tightest right-edge split); if the next segment is another variable, take the FIRST self-anchor (leave room for the following variable to consume its share). This is what makes {a?:_}{b?:_}file.png on first_second_file.png split into a=first, b=second rather than a=first_second, b="".
Optional variables (?) — how ambiguity is resolved
An optional variable may or may not have emitted at the time the path was written. Reverse-matching enumerates all 2ᵏ combinations of "each optional emitted vs. omitted" (where k is the number of optional-unbound variables in the template), extracts each combination, and validates via forward round-trip: the extracted values are re-resolved through the template and the result must match the input path byte-for-byte.
Combinations are tried in popcount-descending order — the richest interpretation (most optionals emitted, most information recovered) is preferred over lossier readings. The first combination whose round-trip succeeds wins.
Worked example — the canonical case:
Template: {workspace_dir}/{sub_dirs?:/}{file_name_base}{###?:^_v}.{file_extension}
Known: workspace_dir="/ws", file_extension="py"
Path: /ws/my_flow_v001.py
Attempt 1 (both optionals emitted): sub_dirs="my_flow", file_name_base="",
_index=1 → resolves to "/ws/my_flow/_v001.py" — MISS.
Attempt 2 (sub_dirs on, _index off): sub_dirs="my_flow_v001", file_name_base=""
→ resolves to "/ws/my_flow_v001/.py" — MISS.
Attempt 3 (sub_dirs off, _index on): file_name_base="my_flow", _index=1
→ resolves to "/ws/my_flow_v001.py" — MATCH ✓
The 4th combination is not attempted once the 3rd wins.
Consequences worth knowing:
- An emitted optional that captures the empty string is rejected before the round-trip runs — capturing an empty value contradicts the "the slot emitted" assumption.
- Round-trip validation catches greedy misreads: an extraction that resolves to a different string than the input path can never win. This is why the extractor's greedy anchor choices are safe — a wrong choice fails to round-trip and the next combination is tried.
- When multiple combinations round-trip, the highest-popcount one wins. Ties within a popcount are broken by mask value (ascending), which is deterministic but has no semantic meaning — see "Building unambiguous templates" below.
Building unambiguous templates
Two variables with no fixed text between them are grammatically ambiguous when both are unbound — the grammar has no way to say which side of the ambiguous boundary a character belongs to. Reverse-matching will still return some valid answer (any reading that round-trips is legal), but which specific reading isn't predictable across templates.
Design guidance:
- Put a static separator (or a distinctive leading-separator prefix) between adjacent variables when you want reliable reverse-matching.
{name}_{version}is unambiguous;{name}{version}isn't. - The sequence-slot form with a leading separator —
{###?:^_v}— is the recommended pattern for versioned filenames. The_vprefix is a distinctive anchor that lets the extractor find the version boundary regardless of what comes before it. - Pre-supply values you know via
known_variables. Every known variable removes one dimension from the 2ᵏ search and eliminates one source of ambiguity.
Format specs on the reverse path
Not every format spec has a lossless inverse. When a spec can't be reversed unambiguously the extractor returns the raw string and lets the caller decide what to do with it.
| Format spec | Reversal behavior |
|---|---|
:03 (numeric padding) |
Parsed as an integer ("005" → 5). A non-numeric value fails the spec and disqualifies that extraction attempt. |
{###} / {###?} (sequence slot) |
Same as numeric padding — parses to int. When bound, the value is available as _index. |
:_ (trailing separator) |
Strips the trailing separator if present. Idempotent when absent. |
:^_v (leading separator) |
Strips the prefix if present. Idempotent when absent. When followed by a preceding variable's extraction, the prefix also serves as an anchor. |
:lower / :upper / :title / … |
Case transformations return the extracted substring as-is; the original casing is not recoverable. Callers that need round-trip fidelity should avoid these on match keys. |
:slug / :snake / :pascal / … |
Same — one-way transforms return the raw extracted value. |
\|default_value |
Defaults are a forward-only construct. On the reverse path the variable is either extracted from the path or left unbound; the default text is never re-injected. |
Practical limits
- Reverse-matching caps the number of optional-unbound variables at 5 (2⁵ = 32 combinations). Templates with 6 or more optional-unbound variables raise
MacroParseFailureReason.TOO_MANY_OPTIONAL_VARIABLES. Real-world templates in the tree top out at 3 optionals; the cap exists to prevent runaway work on pathological grammars. - The cap counts only optionals that aren't bound by
known_variables. Pre-binding an optional removes it from the search space, so a template with 6 optional variables can still reverse-match if all 6 are supplied by the caller. - Path matching is byte-exact. Cross-platform callers should normalize path separators to forward slashes before calling
extract_variables— the built-in project-directory match handler does this automatically for auto-resolved directory builtins.
Syntax errors
The macro parser reports syntax errors with a position number to help you find the problem:
- Unclosed brace:
{variable_name(no closing}) - Unmatched closing brace:
variable}name - Nested braces:
{outer{inner}} - Empty variable:
{}