Control Flow
Control how your DAG executes with conditions, dependencies, and repetition.
Dependencies
Define execution order with step dependencies.
Basic Dependencies
tools:
- astral-sh/uv@0.11.14
steps:
- id: download
run: wget https://example.com/data.zip # Download archive
- id: extract
run: unzip data.zip # Extract files
depends: download
- id: process
run: uv run --python 3.13.9 python process.py # Process data
depends: extractExplicit Dependencies
Use depends when you need parallel execution or custom dependency relationships:
steps:
- id: download_a
run: wget https://example.com/a.zip
- id: download_b
run: wget https://example.com/b.zip
- id: merge
run: echo "Merging a.zip and b.zip"
depends: [download_a, download_b]Modular Workflows and Iteration Patterns
Nested Workflows
Run other workflows as steps and compose them hierarchically. See Sub-DAGs for the full reference.
steps:
- id: extract
action: dag.run
with:
dag: workflows/extract.yaml
params: "source=production"
- id: transform
action: dag.run
with:
dag: workflows/transform.yaml
params: "source=production"
depends: extract
- id: load
action: dag.run
with:
dag: workflows/load.yaml
params: "target=warehouse"
depends: transformNote: Sub-DAGs do not inherit
handler_onfrom the base configuration. Each nested workflow should define its own lifecycle handlers if needed. See Sub-DAG Handler Isolation for details.
Note: Sub-DAGs also do not inherit parent DAG
tools. If a child workflow uses a managed external command, declaretoolsin the child workflow. See Tools: Sub-DAGs.
Synchronous vs Asynchronous Child Workflows
Use action: dag.run when the parent must wait for the child DAG to finish before continuing. The child runs as a sub-DAG of the parent, and the parent step reflects the child result.
Use action: dag.enqueue when the parent only needs to queue another DAG and continue once the queue item is created. The child becomes its own top-level DAG run with status queued, while the parent keeps a reference to it in the sub-DAG run list for traceability.
params:
- name: date
default: 2026-01-01
steps:
- id: fanout_report
action: dag.enqueue
with:
dag: workflows/report.yaml
params:
date: ${params.date}
queue: background
- id: continue_parent
run: echo "Report workflow was queued"
depends: fanout_reportIf with.queue is omitted, the child DAG uses its own queue setting, base-config default, or local DAG queue. Use dag.run instead when later parent steps need the child output or success/failure result before they execute.
Working Directory:
A child DAG run does not inherit the parent's working directory. Each child run gets its own work directory, so parallel children cannot collide on relative paths:
working_dir: /app/project
steps:
- action: dag.run
with:
dag: child-task # Child runs in its own work directory
---
name: child-task
steps:
- run: pwd # Outputs the child run work directoryTo pin a child to a specific directory, declare working_dir in the child DAG:
# child-with-custom-dir.yaml
working_dir: /custom/path
steps:
- run: pwd # Outputs: /custom/pathSee Sub-DAGs: Working Directory.
Multiple DAGs in One File
Define multiple DAGs separated by --- and call by name.
steps:
- action: dag.run
with:
dag: data-processor
params: "type=daily"
---
name: data-processor
params:
- name: type
default: batch
steps:
- id: extract
run: echo "Extracting ${params.type} data"
- id: transform
run: echo "Transforming data"
depends: extractDynamic Iteration
Discover work at runtime and iterate over it in parallel.
steps:
- id: discover_tasks
run: |
printf 'tasks=file1.csv,file2.csv,file3.csv\n' >> "$DAGU_OUTPUT_FILE"
outputs:
- name: tasks
- id: process_tasks
action: dag.run
with:
dag: worker
params: "file=${ITEM}"
parallel:
items: ${steps.discover_tasks.outputs.tasks}
max_concurrent: 1
depends: discover_tasks
---
name: worker
params:
- name: file
default: ""
steps:
- id: process_file
run: echo "Processing ${params.file}"Map-Reduce Pattern
Split, map in parallel, then reduce results.
steps:
- id: split_chunks
run: |
printf 'chunks=chunk1,chunk2,chunk3\n' >> "$DAGU_OUTPUT_FILE"
outputs:
- name: chunks
- id: map_chunks
action: dag.run
with:
dag: worker
params: "chunk=${ITEM}"
parallel:
items: ${steps.split_chunks.outputs.chunks}
max_concurrent: 3
depends: split_chunks
- id: reduce_results
run: printf '%s\n' "${map_chunks.outputs}" | jq '[.[].ROWS | tonumber] | add'
depends: map_chunks
---
name: worker
params:
- name: chunk
default: ""
steps:
- id: process_chunk
run: ./process.sh "${params.chunk}"
output: ROWS${map_chunks.outputs} is the JSON array of each successful child's outputs, in item order, so the reduce step reads the mapped values directly instead of writing them to shared files. See Reading Child Outputs for the ordering and failure rules.
Conditional Execution
Run steps only when conditions are met.
Basic Preconditions
steps:
- run: echo "Deploying to production"
preconditions:
- condition: "${env.ENVIRONMENT}"
expected: "production"With expected, condition compares the resolved string value, or a number when expected uses the num: prefix described in Numeric Comparison. Backticks and $() stay literal in this form; use eval when the compared value must be computed dynamically.
When expected is omitted, Dagu treats condition as a command check. Dagu first replaces variables in the condition string. If a shell is configured, the result runs through that shell. Without a shell, Dagu executes the resulting string directly, so shell syntax requires an active shell.
steps:
- run: echo "Threshold reached"
with:
shell: bash
preconditions:
- condition: "test ${env.DEV_PCENT} -ge ${env.DEV_ALERT}"For a numeric comparison you no longer need a shell. See Numeric Comparison, which compares the values directly and works the same way on Windows.
Command Output Conditions
steps:
- run: echo "Deploying application"
preconditions:
- eval: "$(git branch --show-current)"
expected: "main"eval accepts both $() and backtick command substitution. The $() form is usually easier to read and quote, but the backtick form is also valid:
steps:
- run: echo "Deploying application"
preconditions:
- eval: "`git branch --show-current`"
expected: "main"Regex Matching
steps:
# Run only on weekdays
- run: echo "Running batch job"
preconditions:
- eval: "$(date +%u)"
expected: "re:[1-5]" # Monday-FridayNote: Use eval only with expected. For plain exit-code checks, omit expected and keep the command in condition.
Numeric Comparison
Use the num: prefix to compare numbers instead of text. The operators are >, >=, <, and <=:
steps:
- id: score
run: echo 0.87
output: CONFIDENCE
- id: handle_automatically
depends: score
preconditions:
- condition: "${CONFIDENCE}"
expected: "num:>=0.8"
run: echo "Confident enough to handle without review"Both sides are compared as numbers, so 0.9 is greater than 0.45 even though the text "0.9" sorts after "0.45". This replaces shelling out to test or awk, which also makes the check portable to Windows runners.
There is no == operator. Exact string matching already covers equality, and comparing floating-point numbers for equality is unreliable.
The threshold can come from a variable, so one workflow can carry a different bar per environment:
params:
- threshold: "0.8"
steps:
- id: score
run: echo 0.87
output: CONFIDENCE
- id: handle_automatically
depends: score
preconditions:
- condition: "${CONFIDENCE}"
expected: "num:>=${threshold}"
run: echo "Above the configured bar"A threshold reference must be the whole number and nothing else: num:>=${threshold} is valid, num:>=0.${threshold} is not. Any reference form that works in condition works here, including ${params.threshold}, ${env.THRESHOLD}, and $THRESHOLD.
A value that is not a number fails the step
A comparison that simply does not hold skips the step, exactly like any other unmet precondition. But a value that is not a number at all, such as empty text or an unresolved reference, is an evaluation error and fails the step instead.
That is deliberate: a numeric gate that silently stopped gating would let work through unchecked. If a value may legitimately be missing, guard it with a separate precondition first.
Numeric matching reads the whole value, not one line at a time as literal and regex matching do. Surrounding whitespace is ignored, so trailing newlines are fine, but a multi-line value is never a number.
Combine two bounds to gate on a range, since conditions are combined with AND:
steps:
- id: score
run: echo 0.5
output: CONFIDENCE
- id: human_review
depends: score
preconditions:
- condition: "${CONFIDENCE}"
expected: "num:<0.9"
- condition: "${CONFIDENCE}"
expected: "num:>0.1"
run: echo "Middle band, send to a human"Multiple Conditions
All conditions must pass:
steps:
- run: echo "Deploying application"
preconditions:
- condition: "${env.ENVIRONMENT}"
expected: "production"
- condition: "${env.APPROVED}"
expected: "true"
- eval: "$(date +%H)"
expected: "re:0[8-9]|1[0-7]" # 8 AM - 5 PMNegated Conditions
Use negate: true to invert condition logic. The step runs when the condition does not match:
steps:
# Skip deployment in production environment
- run: echo "Running experimental feature"
preconditions:
- condition: "${env.ENVIRONMENT}"
expected: "production"
negate: true # Runs only when NOT in productionWith command-based conditions, negate inverts the exit code check:
steps:
# Run only if service is NOT running
- run: echo "Starting service"
preconditions:
- condition: "pgrep -f my-service"
negate: true # Runs when command fails (service not found)Combine negate with regex patterns for exclusion logic:
steps:
# Skip on weekends
- run: echo "Running weekday job"
preconditions:
- eval: "$(date +%u)"
expected: "re:[67]" # 6=Saturday, 7=Sunday
negate: true # Runs when NOT weekendFile/Directory Checks
steps:
- run: echo "Processing"
preconditions:
- condition: "test -f /data/input.csv"
- condition: "test -d /output"Router Steps
Route execution to different steps based on a runtime value. Router steps evaluate an expression and run all target steps whose pattern matches. Router steps run in graph workflows, which is the default.
Basic Routing
env:
- STATUS: production
steps:
- id: router
action: router.route
with:
value: ${env.STATUS}
routes:
"production": [prod_handler]
"staging": [staging_handler]
- id: prod_handler
run: echo "Deploying to production"
- id: staging_handler
run: echo "Deploying to staging"Regex Patterns
Use the re: prefix for pattern matching:
env:
- INPUT: apple_pie
steps:
- id: router
action: router.route
with:
value: ${env.INPUT}
routes:
"re:^apple.*": [apple_handler]
"re:^banana.*": [banana_handler]
- id: apple_handler
run: echo "Apple route"
- id: banana_handler
run: echo "Banana route"Numeric Routes
A route pattern accepts num: as well, so a run can branch on a number. Keep the routes mutually exclusive and exactly one target runs:
type: graph
env:
- SCORE: "0.95"
steps:
- id: router
action: router.route
with:
value: ${env.SCORE}
routes:
"num:>=0.9": [auto_approve]
"num:<0.9": [needs_review]
- id: auto_approve
run: echo "Approved automatically"
- id: needs_review
run: echo "Sent for review"Note: listing one step under two numeric routes, such as
num:<0.1andnum:>0.9, sends both ends of a range to it. A middle band such as0.1 < x < 0.9has no route form, because routes listing the same step match when either one does. Put the bounds on the target step as two preconditions instead, as shown in Numeric Comparison.
A numeric route fails the run on a non-numeric value
When a workflow declares any num: route and the value is not a number, the router step itself fails and no target runs. That includes a target whose pattern matches the text and a re:.* catch-all. A routing decision that cannot be evaluated is not partly carried out.
So a catch-all cannot be used as a safety net for non-numeric input. Make sure the value is a number before routing on it.
Catch-All Route
Use re:.* as a default fallback:
env:
- INPUT: unknown_value
steps:
- id: router
action: router.route
with:
value: ${env.INPUT}
routes:
"specific": [specific_handler]
"re:.*": [default_handler]
- id: specific_handler
run: echo "Specific route"
- id: default_handler
run: echo "Default route"Multiple Targets Per Route
A single pattern can dispatch to multiple steps:
env:
- INPUT: trigger
steps:
- id: router
action: router.route
with:
value: ${env.INPUT}
routes:
"trigger": [step_a, step_b]
- id: step_a
run: echo "Step A"
- id: step_b
run: echo "Step B"One Step Under Several Routes
A step can be listed under more than one route. It runs once when any of those routes matches:
params:
- OP: UPDATE
steps:
- id: router
action: router.route
with:
value: ${OP}
routes:
INSERT: [generate_thumbnails, index_object]
UPDATE: [generate_thumbnails]
- id: generate_thumbnails
run: echo "Generating thumbnails"
- id: index_object
run: echo "Indexing new object"Routing Based on Step Output
Use a previous step's output as the router value:
steps:
- id: check_status
run: printf 'status=success\n' >> "$DAGU_OUTPUT_FILE"
outputs:
- name: status
- id: router
action: router.route
with:
value: ${steps.check_status.outputs.status}
routes:
"success": [success_handler]
"failure": [failure_handler]
depends: check_status
- id: success_handler
run: echo "Handling success"
- id: failure_handler
run: echo "Handling failure"Routing on a Model Decision
Classify with decision.evaluate and route on the answer. The options are declared as criteria, so the route keys and the option names are the same list:
type: graph
steps:
- id: classify
action: decision.evaluate
with:
provider: openrouter
model: typesafe/jev-1.13
state: The checkout page returns a 500 after the payment step.
questions:
department:
type: choice
instructions: Which department should handle this?
criteria:
billing: Charges and refunds
engineering: Bugs and outages
- id: route
action: router.route
with:
value: ${classify.output.answers.department.choice}
routes:
billing: [handle_billing]
engineering: [handle_engineering]
depends: classify
- id: handle_billing
run: echo "Billing queue"
- id: handle_engineering
run: echo "Engineering queue"Each answer also carries a confidence, so a numeric route or precondition can send the uncertain cases to a person instead. See Numeric Comparison.
Chained Routers
Nest routers for multi-level decisions:
env:
- CATEGORY: electronics
- SUBCATEGORY: phone
steps:
- id: category_router
action: router.route
with:
value: ${env.CATEGORY}
routes:
"electronics": [electronics_router]
"clothing": [clothing_handler]
- id: electronics_router
action: router.route
with:
value: ${env.SUBCATEGORY}
routes:
"phone": [phone_handler]
"laptop": [laptop_handler]
- id: phone_handler
run: echo "Phone"
- id: laptop_handler
run: echo "Laptop"
- id: clothing_handler
run: echo "Clothing"Evaluation order: Exact and
num:matches are checked first, then regex patterns in alphabetical order, with catch-all (re:.*) last. All matching routes execute their targets, not just the first match.
Constraints: Router steps run in graph workflows. A step targeted by more than one router step runs only when a route of each router matches.
Repetition
Repeat steps with explicit 'while' or 'until' modes for clear control flow.
For iterating over a list of items, use parallel instead.
Repeat While Mode
The 'while' mode repeats a step while a condition is true.
steps:
- run: nc -z localhost 8080
repeat_policy:
repeat: while
exit_code: [1] # Repeat WHILE connection fails (exit code 1)
interval_sec: 10 # Wait 10 seconds between attempts
limit: 30 # Maximum 30 attemptsRepeat Until Mode
The 'until' mode repeats a step until a condition becomes true.
steps:
- run: check-job-status.sh
env:
- STATUS: COMPLETED
repeat_policy:
repeat: until
condition: "${env.STATUS}"
expected: "COMPLETED" # Repeat UNTIL status is COMPLETED
interval_sec: 30
limit: 120 # Maximum 1 hourrepeat_policy.expected accepts the same re: and num: prefixes as a precondition, so a step can wait for a number to cross a threshold:
steps:
- id: wait_for_backlog_to_drain
run: queue-depth.sh
output: DEPTH
repeat_policy:
repeat: until
condition: "${DEPTH}"
expected: "num:<=10" # Repeat UNTIL the backlog is 10 or fewer
interval_sec: 30
limit: 120Note: always set
limiton anuntilrepeat. Without it the step repeats indefinitely while the condition stays unmet.
Stopping a Repeating Step
When a run is stopped while a repeating step is between attempts, for example while it checks its repeat condition or waits for interval_sec, the pending repetition is canceled and the step ends as aborted, as does the run. An attempt that is already running when the stop arrives can finish within the DAG's cleanup time, and the step then keeps that attempt's result instead of repeating.
Conditional Repeat Patterns
While Process is Running
steps:
- run: pgrep -f "my-app"
repeat_policy:
repeat: while
exit_code: [0] # Exit code 0 means process found
interval_sec: 60 # Check every minuteUntil File Exists
steps:
- run: test -f /tmp/output.csv
repeat_policy:
repeat: until
exit_code: [0] # Exit code 0 means file exists
interval_sec: 5
limit: 60 # Maximum 5 minutesWhile Condition with Output
steps:
- run: curl -sf http://api/health
env:
- HEALTH_STATUS: healthy
repeat_policy:
repeat: while
condition: "${env.HEALTH_STATUS}"
expected: "healthy"
interval_sec: 30Exponential Backoff for Repeats
Gradually increase intervals between repeat attempts:
steps:
# Exponential backoff with while mode
- run: nc -z localhost 8080
repeat_policy:
repeat: while
exit_code: [1] # Repeat while connection fails
interval_sec: 1 # Start with 1 second
backoff: true # true = 2.0 multiplier
limit: 10
# Intervals: 1s, 2s, 4s, 8s, 16s, 32s...
# Custom backoff multiplier with until mode
- run: check-job-status.sh
env:
- STATUS: COMPLETED
repeat_policy:
repeat: until
condition: "${env.STATUS}"
expected: "COMPLETED"
interval_sec: 5
backoff: 1.5 # Gentler backoff
limit: 20
# Intervals: 5s, 7.5s, 11.25s, 16.875s...
# Backoff with max interval cap
- run: curl -s https://api.example.com/status
env:
- API_STATUS: ready
repeat_policy:
repeat: until
condition: "${env.API_STATUS}"
expected: "ready"
interval_sec: 2
backoff: 2.0
max_interval_sec: 60 # Never wait more than 1 minute
limit: 100
# Intervals: 2s, 4s, 8s, 16s, 32s, 60s, 60s, 60s...Backoff Formula: interval * (backoff ^ attemptCount)
Variable References in Repeat Policy
The interval_sec, limit, and max_interval_sec fields accept scoped value references that are resolved at runtime. This lets you parameterize repeat behavior through environment variables or DAG parameters.
env:
- REPEAT_LIMIT: 10
- POLL_INTERVAL: 5
steps:
- run: echo "repeating"
repeat_policy:
repeat: true
limit: ${env.REPEAT_LIMIT}
interval_sec: ${env.POLL_INTERVAL}Repeat-policy numeric fields do not execute command substitutions. Compute a dynamic integer through an inline parameter eval, then reference it:
params:
- name: repeat_limit
type: integer
eval: "`printf 3`"
steps:
- run: echo "repeating"
repeat_policy:
repeat: true
limit: ${params.repeat_limit}
interval_sec: 0The values must resolve to valid integers at runtime. If a variable reference cannot be resolved or does not produce an integer, the step fails during preparation.
Continue On Conditions
Continue on Failure
steps:
- id: cleanup
run: echo "Cleaning up"
continue_on: failed # Shorthand syntax
- id: process
run: echo "Processing"
depends: cleanupContinue on Specific Exit Codes
steps:
- id: check_status
run: echo "Checking status"
continue_on:
exit_code: [0, 1, 2] # Continue on these codes
- id: process
run: echo "Processing"
depends: check_statusContinue on Output Match
steps:
- id: validate
run: echo "Validating"
continue_on:
output:
- "WARNING"
- "SKIP"
- 're:^\[WARN\]' # Regex: lines starting with [WARN]
- "re:error.*ignored" # Regex: error...ignored pattern
- id: process
run: echo "Processing"
depends: validateNote:
continue_on.outputtakes literal substrings andre:patterns only. Thenum:prefix is rejected when the workflow loads, because matching here reads one line at a time while a numeric comparison reads a whole value. Numeric comparison applies topreconditionsand router routes. To match output that begins withnum:, write it as a regex, such asre:num:>=5.
Continue on Skipped
steps:
- id: enable_feature
run: echo "Enabling feature"
preconditions:
- condition: "${env.FEATURE_FLAG}"
expected: "enabled"
continue_on: skipped # Shorthand syntax
- id: process
run: echo "Processing" # Runs regardless of optional feature
depends: enable_featureMark as Success
steps:
- run: echo "Running optional task"
continue_on:
failure: true
mark_success: true # Mark step as successfulComplex Conditions
Combine multiple conditions for sophisticated control flow:
steps:
# Tool with complex exit code meanings
- id: analyze_data
run: echo "Analyzing data"
continue_on:
exit_code: [0, 3, 4, 5] # Various non-error states
output:
- "Analysis complete with warnings"
- "re:Found [0-9]+ minor issues"
mark_success: true
# Graceful degradation pattern
- id: try_advanced_method
run: echo "Processing with advanced settings"
continue_on:
failure: true
output: ["FALLBACK REQUIRED", "re:.*not available.*"]
- id: simple_method
run: echo "Processing with simple settings"
preconditions:
- condition: "${env.TRY_ADVANCED_METHOD_EXIT_CODE}"
expected: "re:[1-9][0-9]*"
depends: try_advanced_method
# Skip pattern with continuation
- id: optional_feature
run: echo "Running feature"
preconditions:
- condition: "${env.ENABLE_FEATURE}"
expected: "true"
continue_on:
skipped: true # Continue if precondition not metSee the Continue On Reference for complete documentation.
DAG-Level Conditions
Preconditions
preconditions:
- eval: "$(date +%u)"
expected: "re:[1-5]" # Weekdays only
steps:
- run: echo "Running daily job"Negated DAG Preconditions
Use negate: true at the DAG level to skip the entire workflow when conditions match:
# Skip this DAG in production
preconditions:
- condition: "${env.ENVIRONMENT}"
expected: "production"
negate: true # DAG runs only when NOT in production
steps:
- run: echo "Running development task"# Run maintenance only outside business hours
preconditions:
- eval: "$(date +%H)"
expected: "re:0[9]|1[0-7]" # 9 AM - 5 PM
negate: true # Runs when NOT during business hours
steps:
- run: echo "Running maintenance"Skip If Already Successful
schedule: "0 * * * *" # Every hour
skip_if_successful: true # Skip if already ran successfully today (e.g., run manually)
steps:
- run: echo "Syncing data"
