Home DevOps Airflow Branching: Skipped Tasks That Failed the DAG
Intermediate 3 min · August 1, 2026
Airflow Branching and Trigger Rules

Airflow Branching: Skipped Tasks That Failed the DAG

Airflow branching skips tasks by design, but the wrong trigger rule fails the DAG.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.

Follow
Production
production tested
August 01, 2026
last updated
1,750
articles · all by Naren
Before you start⏱ 15-20 min read
  • Airflow running with a multi-task DAG you can observe in the grid view.
  • Comfort with task dependencies using >> and trigger rules.
  • A workflow with a genuine fork: two alternative paths into one downstream step.
  • Basic Python and DAG authoring experience.
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • BranchPythonOperator picks one path per run; the untaken branch's tasks become skipped, a state that is neither success nor failure.
  • The default trigger rule all_success treats skipped as a failure — the reason a green-looking branch turned our join red every night.
  • Trigger rules are per-task decisions: all_success, none_failed, all_done, one_failed, one_success, and none_skipped each fit one job.
  • Join tasks survive skips with trigger_rule="none_failed", so the DAG proceeds when nothing failed even if a branch never ran.
  • Production rule: every join carries an explicit trigger rule, and every branching DAG is verified with a forced skip scenario before merge.
✦ Definition~90s read
What is Airflow Branching and Trigger Rules?

Airflow branching lets a task choose one of several paths at runtime via BranchPythonOperator, and trigger rules decide per task whether it may start — together they determine how skipped tasks and join tasks behave in a DAG.

Imagine a checkout counter that splits into two lanes: express for small orders, regular for big ones.
Plain-English First

Imagine a checkout counter that splits into two lanes: express for small orders, regular for big ones. The empty lane isn't broken — it just has nothing to do, so its cashiers are skipped. Now imagine a rule that says "the whole store closes unless every cashier worked today." That's all_success. Our join task used that rule, so every night the untouched lane shut the whole DAG down. The fix: a rule that says "close only if someone actually broke something" — none_failed.

Branching looks like the simplest thing in Airflow: if this, run that task; else, run the other. Then the nightly DAG went red at a join task — while every task that actually ran was green.

The grid view told the story. One branch executed. The other was skipped — by design. And the join task, which inherited the default trigger rule all_success, decided a skipped task is as good as a failed one. Red DAG, green graph.

Here's BranchPythonOperator, the skip state, and the trigger rules that decide whether your graph continues. Then it hands you the join patterns that survive skips and the debug flow for skipped graphs.

The default trigger rule is a landmine in any branching DAG. Learn to disarm it before your join goes red at 2 AM.

1. Branching with BranchPythonOperator

BranchPythonOperator wraps a callable that returns the task_id of the next task to run. Airflow then executes that path and skips the rest of the branch. One return value, one path — that contract matters.

The branch callable is plain Python: any condition, any input, any upstream data you pass via op_args. The logic stays testable outside Airflow entirely.

Our payments DAG routes large orders to manual_review and everything else to auto_approve. The untaken side becomes skipped. That skip is about to become the whole story.

The docs bless two return shapes beyond a single task_id: a list of task_ids or task_group_ids, which follows several paths at once, and None, which skips every downstream task. Whatever you return must point to a task directly downstream of the branch — a stale id means the intended path silently never runs.

payments_workflow.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from airflow.decorators import dag, task
from airflow.operators.python import BranchPythonOperator, PythonOperator
from pendulum import datetime


@dag(schedule="@daily", start_date=datetime(2026, 8, 1), catchup=False, tags=["payments"])
def payments_workflow():
    def choose_payment_path():
        if getattr(context_placeholder, "amount", 0) > 10000:
            return "manual_review"
        return "auto_approve"

    branch = BranchPythonOperator(
        task_id="choose_payment_path",
        python_callable=choose_payment_path,
    )
    auto_approve = PythonOperator(task_id="auto_approve", python_callable=lambda: print("approved"))
    manual_review = PythonOperator(task_id="manual_review", python_callable=lambda: print("reviewed"))

    branch >> [auto_approve, manual_review]


payments_workflow()
Output
2026-07-15: amount=842 → auto_approve; manual_review and its downstream skipped
📊 Production Insight
The branch callable returns one task_id.
The untaken arm becomes skipped, not failed.
Test the callable before you trust the branch.
🎯 Key Takeaway
BranchPythonOperator picks one path per run.
Everything else is skipped by design.
The skip is about to matter more than the branch.
Branching: One Path per Run Branching: One Path per Run The untaken branch becomes skipped BranchPythonOperator Callable returns the next task_id auto_approve Default: everything else Executed path runs manual_review Large orders (>10000) Executed path runs Untaken path is skipped A third state: not success or failure One return value, one path Test the callable before trusting it THECODEFORGE.IO
thecodeforge.io
Airflow Branching Triggers

2. The Skip State and How It Propagates

Skipped is a terminal state for that task instance — the task had nothing to do. It's not success, and it's not failure. It's a third thing, and every rule downstream has to decide what it means.

Skips cascade. When a task is skipped, its downstream tasks evaluate their trigger rules against the actual states; under all_success they skip too. That's how one untaken branch can grey out half the graph.

Read the grid view with that in mind: grey means "decided not to run," not "broke." The question is always whether the task after the grey one can live with it.

Mental Model
Skipped Means "Nothing to Do", Not "Done"
A skipped task didn't fail and didn't succeed — it never ran, and every rule downstream has to decide what that means.
  • Skipped is its own state, tracked like any other in the grid view.
  • The default all_success treats skipped as a broken promise.
  • Cascades are by design: children of a skipped task skip too, unless the join rule says otherwise.
📊 Production Insight
Skipped is a third state, not a quiet success.
Cascades are by design, not a bug.
Read grey as 'decided not to run', then check the join.
🎯 Key Takeaway
Skipped never ran; it was decided out of existence.
all_success reads skipped as failure.
The skip state is a rule question waiting to happen.
The Skip State and Its Cascade The Skip State and Its Cascade Skipped is a third state — never ran, by decision Skipped is a third state Not success, not failure — never ran Downstream reads the skip Each task's trigger rule decides none_failed Runs despite the skip The join-rule fix all_success (default) Skip reads as failure Red join every night Skips cascade downstream Grey means 'decided not to run' THECODEFORGE.IO
thecodeforge.io
Airflow Branching Triggers

3. Trigger Rules: The Full Table with Production Meaning

A trigger rule decides when a task may start after its upstream tasks finish. The default is all_success — right for linear pipelines, quietly fatal for anything with branches.

The table below is the reference. The three you'll actually use: all_success for linear chains, none_failed for joins, all_done for teardown and notifications. one_failed and all_failed exist for alerting and rollback fan-ins.

One nuance the docs bury: when an upstream can't run because its own upstream failed, it's upstream_failed, not failed — and all_done still counts it. Learn the states before you learn the rules.

Two edge cases decide real incidents. First, when an all_success task sees both a skipped and a failed upstream, its final state depends on which non-successful state the scheduler evaluates first — a failure landing in the same evaluation period wins. Second, none_failed_min_one_success proves nothing when nothing ran: if every upstream is skipped, the join is skipped too, because 'no failure' is not 'data produced'.

📊 Production Insight
Trigger rules are per-task decisions, never defaults.
all_success is for linear chains only.
Teardown gets all_done; joins get none_failed.
🎯 Key Takeaway
The default rule is a landmine in branching DAGs.
Each rule exists for one job; pick deliberately.
Learn the states before you trust the rules.
Which Trigger Rule Do You Need?
IfEvery upstream must genuinely run and succeed
Useall_success — the default, correct for linear pipelines only.
IfAny upstream failure should block the join, skips are fine
Usenone_failed — the join rule that survives branches.
IfThe join must also prove at least one branch produced data
Usenone_failed_min_one_success — none_failed plus a data guarantee.
IfThe task must run no matter what happened upstream
Useall_done — teardown, cleanup, and notifications.
IfThe task exists to react to failure
Useone_failed or all_failed — alerting and rollback fan-ins.

4. Join-Task Patterns That Survive Skips

The join is where branches reunite, and it's where the incident lived. With the default all_success, a skipped arm fails the join. The fix pattern: trigger_rule="none_failed" — run when no upstream failed, regardless of skips.

When the joined data itself is required — at least one branch must have produced something — use none_failed_min_one_success. That's the join rule that turns "nothing broke" into "something actually ran."

The rest of the pattern is placement: the join is one explicit task, named for the merge, with a rule written in code and reviewed like a dependency.

payments_join.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from airflow.decorators import dag, task
from airflow.operators.python import BranchPythonOperator, PythonOperator
from pendulum import datetime


@dag(schedule="@daily", start_date=datetime(2026, 8, 1), catchup=False, tags=["payments"])
def payments_workflow():
    def choose_payment_path():
        return "auto_approve"

    branch = BranchPythonOperator(task_id="choose_payment_path", python_callable=choose_payment_path)
    auto_approve = PythonOperator(task_id="auto_approve", python_callable=lambda: print("approved"))
    manual_review = PythonOperator(task_id="manual_review", python_callable=lambda: print("reviewed"))
    notify = PythonOperator(task_id="notify_ops", python_callable=lambda: print("notified"))

    branch >> [auto_approve, manual_review]
    [auto_approve, manual_review] >> notify

    reconcile = PythonOperator(
        task_id="reconcile_payments",
        python_callable=lambda: print("reconciled"),
        trigger_rule="none_failed",  # survives the skipped branch
    )
    notify >> reconcile


payments_workflow()
Output
choose_payment_path → auto_approve; manual_review skipped; reconcile_payments ran (none_failed)
⚠ all_success Is the Default That Bites
Every join task starts life with all_success. Add a branch and the join silently becomes a landmine: one skipped path and the whole DAG goes red. Make trigger_rule an explicit, reviewed decision on every join.
📊 Production Insight
Joins need explicit rules, never the default.
none_failed survives skips; the min_one variant demands data.
A join without a rule is a landmine with a due date.
🎯 Key Takeaway
The join task is where branch DAGs die.
Write the rule in code and review it like a dependency.
none_failed: nothing broke, so proceed — skips are fine.

5. Debugging Skipped Graphs

A red join with all-green upstreams is a rule problem, not a code problem. Verify before you fix: check the states of every upstream of the join, then re-render what the branch callable actually returned.

airflow tasks states prints the whole run's states in one shot — you'll see success, skipped, and the red join side by side. airflow tasks render shows the params the task really ran with, including the branch callable's inputs.

The discipline that ends these incidents: never fix a skipped-graph failure by reading code. Read states first, then rules, then code.

debug_skips.shBASH
1
2
3
4
5
6
7
8
# The state of every task in one run
airflow tasks states payments_workflow 2026-07-15

# What the branch callable actually returned
airflow tasks render payments_workflow choose_payment_path 2026-07-15

# Clear and re-run just the join after a rule change
airflow tasks clear payments_workflow --task-regex reconcile_payments
Output
choose_payment_path: success | auto_approve: success | manual_review: skipped | notify_ops: skipped | reconcile_payments: failed
📊 Production Insight
States first, rules second, code third.
A red join with green upstreams is a rule problem.
Render the branch inputs before you trust the output.
🎯 Key Takeaway
The grid view is the evidence, not the verdict.
Read states and rules before touching code.
Skipped upstreams failing a join is expected behavior — until you change the rule.
Debugging a Skipped Graph Debugging a Skipped Graph States first, rules second, code third Red join, green upstreams A rule problem, not a code problem 1. Read the states airflow tasks states — whole run 2. Render the branch airflow tasks render — inputs 3. Read the trigger rules Skipped upstreams fail all_success Fix: explicit join rule none_failed survives skipped branches THECODEFORGE.IO
thecodeforge.io
Airflow Branching Triggers

6. When Branching Hides Design Problems

Branching is a runtime decision inside one DAG. If your branch arms produce different data products, different schedules, or different owners, the branch is hiding a design problem — those are two DAGs.

Airflow 3 makes the split natural: each DAG triggers the next via assets, so the decision point becomes an event instead of an if/else. The graph stays honest.

Also know your cousins: ShortCircuitOperator skips the entire downstream chain when its condition is false — a whole-DAG gate, not a fork. That's a different tool for a different job (deep dive in the conditional-execution article).

📊 Production Insight
Branch arms with different products are separate DAGs.
A fork is a runtime decision; an event is a schedule.
ShortCircuit gates the whole chain; branch picks a path.
🎯 Key Takeaway
If arms diverge in destiny, split the DAG.
Assets turn decisions into events in Airflow 3.
Branching is a tool, not a design philosophy.

7. The Rule That Keeps Joins Green

The postmortem produced three standing rules. First: every join task declares its trigger rule explicitly — none_failed for joins, all_done for teardown, all_success only for true linear chains. Defaults are not decisions.

Second: every branching DAG ships with a verified skip scenario. Run it in dev, force each branch, and read the grid view until you've seen the skipped path come out green.

Third: when the join goes red again — and it will, somewhere — read states, then rules, then code. The fix is usually one line. Finding it is a discipline.

📊 Production Insight
Explicit rules on every join, reviewed like code.
Verify a forced skip scenario before merge.
States first, rules second, code third — every time.
🎯 Key Takeaway
Defaults are not decisions in production.
A branching DAG is done when its skipped path is green.
The red join is usually one line away from green.
● Production incidentPOST-MORTEMseverity: high

The Skip That Failed the Join

Symptom
The nightly DAG started failing at a join task even though every task in the executed branch succeeded. The grid view showed green tasks on one branch, skipped tasks on the other — and a red join between them.
Assumption
The team assumed a skipped task was a neutral state — a task that simply didn't run — so downstream tasks would ignore it.
Root cause
The join task used the default trigger rule all_success, which requires every upstream task to reach success. Skipped is not success. The untaken branch's tasks were skipped by design, so the join failed every night even though nothing actually broke.
Fix
Set trigger_rule="none_failed" on the join task so it runs when no upstream failed, whether they succeeded or were skipped, then verified the branch and skip scenario in the grid view before shipping.
Key lesson
  • Skipped is a distinct state, not a quiet success — all_success treats it as a failure.
  • Default trigger rules are for linear pipelines; any branch needs deliberate rules at the join.
  • Always render a skip scenario in the grid view before you trust a branching DAG.
  • A red join with all-green upstreams is a trigger-rule problem, not a code problem.
Production debug guideSymptom to Action5 entries
Symptom · 01
Join task red while the executed branch is green
Fix
Read the join's trigger_rule and the state of every upstream — skipped upstreams fail all_success joins.
Symptom · 02
A branch task skipped that should have run
Fix
Re-render the branch callable; a return value that isn't a valid task_id skips the intended path.
Symptom · 03
Cleanup or notification tasks never run
Fix
Check their trigger rule; all_success blocks after any failure — switch teardown tasks to all_done.
Symptom · 04
Both branch arms executed
Fix
BranchPythonOperator returns exactly one task_id; a list or wrong id makes downstream behavior unpredictable.
Symptom · 05
A skip cascaded through the whole DAG
Fix
An all_success chain downstream of a skipped task skips everything; insert a join with none_failed.
★ Trigger Rule Triage CommandsFirst-response commands for skipped-task and join failures.
Join task failed though all ran tasks are green
Immediate action
Check the trigger rule and skip states
Commands
airflow tasks states payments_workflow join_decision 2026-07-15
airflow tasks render payments_workflow join_decision 2026-07-15
Fix now
Set trigger_rule=none_failed (or none_failed_min_one_success) on the join.
A branch task shows skipped and you expected it to run+
Immediate action
Review the branch function's return path
Commands
airflow tasks render payments_workflow choose_payment_path 2026-07-15
airflow tasks test payments_workflow choose_payment_path 2026-07-15
Fix now
Return a valid task_id from the branch callable, or the skip is intended behavior.
Cleanup task didn't run after a failure+
Immediate action
Check the teardown trigger rule
Commands
airflow tasks states payments_workflow notify_on_completion 2026-07-15
airflow dags list-runs payments_workflow
Fix now
Use all_done for teardown; all_success skips it whenever anything failed.
Rerun after changing a trigger rule+
Immediate action
Clear and re-run the affected tasks
Commands
airflow tasks clear payments_workflow --task-regex join_decision
airflow dags backfill payments_workflow --start-date 2026-07-14 --end-date 2026-07-15
Fix now
Clear and rerun the scenario to validate the new rule in the grid view.
Trigger Rules at a Glance
Trigger ruleRuns whenProduction meaning
all_successEvery upstream succeededLinear pipelines; fails on any skip — the default that bit us
all_failedEvery upstream failedFan-in that acts on total failure — rollback territory
all_doneAll upstreams finished, any stateTeardown, cleanup, and notifications that must always run
one_failedAt least one upstream failedAlerting tasks that must fire the moment anything breaks
one_successAt least one upstream succeededRedundant sources — take the first path that works
none_failedNo upstream failed; skips allowedThe join rule for branches — the fix in this article
none_skippedNo upstream was skippedWhen every branch must have actually run
none_failed_min_one_successNo failures and at least one successJoins that need data, not just a clean state sheet
⚙ Quick Reference
3 commands from this guide
FileCommand / CodePurpose
payments_workflow.pyfrom airflow.decorators import dag, task1. Branching with BranchPythonOperator
payments_join.pyfrom airflow.decorators import dag, task4. Join-Task Patterns That Survive Skips
debug_skips.shairflow tasks states payments_workflow 2026-07-155. Debugging Skipped Graphs

Key takeaways

1
BranchPythonOperator picks one path; the untaken branch is skipped, not successful.
2
Skipped is its own state
the default all_success treats skips as failures.
3
Trigger rules are per-task decisions; joins need explicit rules, never the default.
4
Use none_failed for joins, all_done only for teardown and notifications.
5
Verify a forced skip scenario in the grid view before merging any branching DAG.

Common mistakes to avoid

4 patterns
×

Leaving the default all_success on join tasks

Symptom
The join goes red whenever a branch is skipped, even though nothing failed
Fix
Set trigger_rule="none_failed" (or none_failed_min_one_success) deliberately on every join.
×

The branch callable returns a task_id that doesn't exist

Symptom
The intended path is skipped and downstreams cascade into grey; the grid view makes no sense
Fix
Validate the returned id against the task dict, and unit-test the callable before wiring the DAG.
×

Using all_done for data-dependent joins

Symptom
The join runs and processes empty or missing data when every upstream failed
Fix
Use all_success or none_failed_min_one_success for data joins; reserve all_done for teardown.
×

Nesting branches without a join at each level

Symptom
Skip cascades and duplicate paths make the graph unpredictable and the rules unreviewable
Fix
Join at every branch point with an explicit rule, or split the DAG into separate pipelines.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What happens to downstream tasks when one upstream task is skipped?
Q02SENIOR
Your join task failed but the graph shows all green except skips. Diagno...
Q03SENIOR
Design a branching DAG where one of three sources may legitimately be em...
Q01 of 03JUNIOR

What happens to downstream tasks when one upstream task is skipped?

ANSWER
Downstream tasks evaluate their trigger rules against the actual states. Under the default all_success, a skipped upstream prevents the downstream task from running, so it skips too. That's the cascade. With a rule like none_failed or all_done, the downstream can still run. Skipped is its own state — not success, not failure.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Why did my DAG fail when every executed task succeeded?
02
What's the difference between skipped and failed?
03
Which trigger rule should a join task use?
04
Can a skip cascade through the whole DAG?
05
BranchPythonOperator or ShortCircuitOperator?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.

Follow
Verified
production tested
August 01, 2026
last updated
1,750
articles · all by Naren
🔥

That's Airflow. Mark it forged?

3 min read · try the examples if you haven't

Previous
Airflow Variables and Pools
11 / 37 · Airflow
Next
Airflow Sensors