What Is Branching?
Branching lets a DAG choose which downstream path to execute based on runtime logic, rather than always running every task. BranchPythonOperator (or the @task.branch decorator in TaskFlow) runs a Python callable that must return the task_id (or a list of task_ids) of the immediate downstream task(s) that should run; every other direct downstream task not returned is automatically marked as skipped rather than executed, and that skip status propagates further downstream unless a trigger rule says otherwise.
Cricket analogy: It's like a captain choosing, after seeing the pitch report, whether to open with a pace attack or spin — one path is taken, the other bowling plan is simply not used that match.
Implementing a Branch
The branching callable receives the task context and returns a string (or list of strings) matching the task_id of the branch to take; downstream tasks are wired with normal >> dependencies from the branch operator to every possible branch, and the operator's internal logic ensures unreturned branches skip. With the TaskFlow API, @task.branch works identically but as a decorator, and its return value can be used directly in a dependency chain just like any other TaskFlow task.
Cricket analogy: It's like a pre-match team meeting where the coach declares a single decision ('we bowl first') and both the bowling-first plan and batting-first plan exist on paper, but only the announced one is actually executed.
from airflow.decorators import dag, task
from airflow.operators.empty import EmptyOperator
from datetime import datetime
@dag(schedule="@daily", start_date=datetime(2026, 1, 1), catchup=False)
def branch_demo():
@task.branch
def choose_path(**context) -> str:
row_count = context["ti"].xcom_pull(task_ids="count_rows")
return "full_reprocess" if row_count == 0 else "incremental_load"
@task
def count_rows() -> int:
return 0 # simulate an empty upstream table
full_reprocess = EmptyOperator(task_id="full_reprocess")
incremental_load = EmptyOperator(task_id="incremental_load")
notify = EmptyOperator(
task_id="notify",
trigger_rule="none_failed_min_one_success", # runs regardless of which branch ran
)
rows = count_rows()
branch = choose_path()
rows >> branch >> [full_reprocess, incremental_load] >> notify
branch_demo()Trigger Rules and Rejoining Branches
By default, a task's trigger_rule is all_success, meaning it only runs if every upstream task succeeded; since a skipped branch counts as neither success nor failure but propagates its skip, a task placed after two diverging branches with the default trigger rule would itself be skipped, because at least one upstream is 'skipped' rather than 'success'. To rejoin branches into a common downstream task, use a trigger rule such as none_failed_min_one_success (run if no upstream failed and at least one succeeded) or none_failed (run as long as nothing failed, even if everything upstream was skipped).
Cricket analogy: It's like a post-match presentation ceremony that, by default rule, only happens if every scheduled session was actually played — but a smarter rule lets it proceed as long as no session was outright abandoned, even if a warm-up session was skipped.
Common trigger rules besides the default all_success include all_failed, all_done (runs regardless of upstream success/failure/skip, useful for cleanup tasks), one_success, none_failed, and none_failed_min_one_success. Choosing the right one is essential whenever a task sits downstream of a branch point.
A common beginner mistake is placing a normal task directly after a branch's diverging paths without adjusting its trigger_rule, then being confused when that task shows as 'skipped' even though the branch that should have led to it succeeded. Remember: the default all_success trigger rule treats a sibling branch's 'skipped' status as a reason to skip too, since it's not literally 'success.'
- BranchPythonOperator (or @task.branch) returns the task_id(s) of the downstream task(s) to run; all others are skipped.
- Branches are wired with normal >> dependencies from the branch task to every possible downstream option.
- The default all_success trigger rule causes tasks downstream of a branch's skipped path to also be skipped.
- Use none_failed_min_one_success or none_failed to rejoin diverging branches into a common downstream task.
- all_done trigger rule is useful for cleanup tasks that should run regardless of upstream outcome.
- Skip status propagates through the DAG unless a trigger rule explicitly overrides the default behavior.
- @task.branch integrates branching directly into the TaskFlow API's dependency chaining syntax.
Practice what you learned
1. What must a BranchPythonOperator's Python callable return?
2. What happens to a direct downstream task of a branch that was NOT returned by the branch callable?
3. Why might a task placed right after two diverging branches show as 'skipped' even though one branch succeeded?
4. Which trigger rule is commonly used to rejoin two branches into a shared downstream task?
Was this page helpful?
You May Also Like
XComs
Learn how Airflow tasks exchange small pieces of data at runtime using XComs, and when to avoid them in favor of external storage.
Sensors
Learn how Airflow Sensors wait for external conditions to become true before downstream tasks proceed, and how to avoid worker-slot starvation.
Variables and Templating
Learn how Airflow Variables provide runtime configuration and how Jinja templating lets task parameters be computed dynamically at execution time.
Related Reading
Related Study Notes in Programming
Browse all study notesApache Spark Study Notes
Programming · 30 topics
ProgrammingApache Flink Study Notes
Programming · 30 topics
ProgrammingHadoop Study Notes
Programming · 30 topics
ProgrammingSnowflake Study Notes
Programming · 30 topics
Programmingdbt (Data Build Tool) Study Notes
Programming · 30 topics
ProgrammingPowerShell Study Notes
Programming · 30 topics