Skip to content

Lifecycle Handlers

Lifecycle handlers let you run extra steps after the main DAG completes. Use the handler_on block to trigger notifications, clean up resources, or kick off follow-up jobs without duplicating logic inside individual steps. Every handler runs in a status-aware context, so ${context.run.status} is available in value-resolved fields and DAG_RUN_STATUS is available to scripts.

Supported Triggers

HandlerTriggerTypical use cases
initRuns before any workflow steps (after DAG-level preconditions pass)Setup tasks, acquire locks, validate environment
successAll steps completed successfully, or the DAG ended in partially_succeeded (some steps failed but were allowed via continue_on)Deliver success notifications, enqueue downstream jobs
failureThe DAG ended with failed or rejected status, including DAG-level precondition evaluation errorsPage on-call, collect diagnostics
abortThe run was aborted by a stop request, queue eviction, timeout cancellation, or unmet DAG-level preconditionRoll back partial work, release locks
waitThe DAG has paused waiting for human approvalNotify approvers, send Slack messages
exitAlways runs after the status-specific handler finishes (including when it fails or is skipped)File system clean-up, archival tasks

Only the handlers you define are executed. The init handler runs first (before any steps), then the main steps execute, then the status-specific handler runs (if present), and finally the exit handler runs last. The wait handler is special: it runs when the workflow pauses for approval, before the workflow completes.

Basic Definition

yaml
# dag.yaml
env:
  - LOCK_NAME: daily-load

handler_on:
  init:
    run: acquire-lock.sh "${env.LOCK_NAME}"   # runs before any steps
  success:
    run: notify.sh "${context.dag.name} (${context.run.id}) succeeded" # runs after a clean finish
  failure:
    run: alert.sh "${context.dag.name} failed" "logs=${context.paths.log_file}"
  abort:
    run: rollback.sh --lock "${env.LOCK_NAME}"
  wait:
    run: notify-approvers.sh "$DAG_WAITING_STEPS" # runs when waiting for approval
  exit:
    run: rm -rf "/tmp/${context.run.id}" # always runs

steps:
  - run: ./extract.sh
  - run: ./load.sh

Each handler is a normal step definition. You can use run, script, action (for built-in, official, or third-party actions), executor, containers, timeouts, or any other step field that makes sense for a single task.

Execution Model

  • The init handler runs first, before any main steps. If it fails, the DAG is aborted and no steps execute.
  • The scheduler waits for all main steps to finish before evaluating status-specific handlers.
  • It chooses the status-specific handler based on the canonical DAG status (partially_succeeded behaves like success).
  • After the status-specific handler finishes (or if none was defined), the exit handler runs last.
  • Handlers are executed sequentially and synchronously. The DAG is still considered running until they finish.
  • If a handler exits with a non-zero status, the overall DAG run ends in failed, even if every main step succeeded.
  • Handler logs appear alongside other steps in the run history and respect the same log retention policy.
  • Each handler receives ${context.run.status} and the DAG_RUN_STATUS environment variable. The value depends on when the handler runs: running (init), succeeded, partially_succeeded, failed, rejected, aborted, or waiting (wait handler).

Sub-DAG Handler Isolation

Important: Sub-DAGs (workflows invoked via call) do not inherit handler_on configuration from the base DAG configuration. This design prevents unintended behavior such as:

  • Double notifications: If a parent DAG has a failure handler that sends alerts, sub-DAGs would also trigger alerts, causing duplicate notifications.
  • Unintended cleanup: Init, exit, or abort handlers meant for the root workflow would also run for every nested sub-DAG.

Each sub-DAG should define its own handlers explicitly if lifecycle handling is needed:

yaml
# parent.yaml
handler_on:
  failure:
    run: notify.sh "Parent DAG failed"  # Only runs for parent

steps:
  - action: dag.run
    with:
      dag: child-workflow
---
# child-workflow (in same file or separate file)
name: child-workflow
handler_on:
  failure:
    run: notify.sh "Child DAG failed"  # Define explicitly if needed

steps:
  - run: process-data.sh

This isolation ensures that each workflow in a hierarchy has predictable, self-contained lifecycle behavior.

Patterns and Integrations

Send Email with the Mail Action

yaml
handler_on:
  failure:
    action: mail.send
    with:
      to: oncall@company.com
      from: dagu@company.com
      subject: "${context.dag.name} failed"
      message: |
        Run ID: ${context.run.id}
        Logs: ${context.paths.log_file}

Run a Follow-up DAG

yaml
handler_on:
  success:
    action: dag.run
    with:
      dag: sync-reporting
      params: |
        parent_run_id: ${context.run.id}

Guaranteed Cleanup

yaml
handler_on:
  exit:
    run: |
      find "/tmp/${context.run.id}" -maxdepth 1 -type f -delete

Notify on Wait (Approval)

When using approval steps, notify stakeholders:

yaml
handler_on:
  wait:
    run: notify-slack.sh "Approval needed: $DAG_WAITING_STEPS"

steps:
  - id: deploy_staging
    run: ./deploy.sh staging
    approval:
      prompt: "Approve production?"
  - id: deploy_prod
    run: ./deploy.sh production
    depends: deploy_staging

The DAG_WAITING_STEPS environment variable contains a comma-separated list of step names currently waiting for approval.

For the complete schema, refer to the YAML specification. Combine handlers with the techniques from Error Handling and Data & Variables to build robust workflow lifecycles.

Released under the MIT License.