The @task decorator, dependencies inferred from function calls, and an honest account of when classic operators are still the better answer.
⏱ 6 min readTopics chapter readerLevel · Medium
01 · Start here
Start here
⏱ 4 min · Topic 1 of 9
Writing a DAG has two halves. You write each task, and you connect the tasks into a shape.
This chapter covers both:
How to connect tasks: >>, lists, chain() and cross_downstream().
How @task turns plain functions into tasks, and their calls into connections.
When a classic operator is still the better choice.
Every output here comes from a real run with Airflow 3.1.
02 · Shapes
Wiring tasks together
⏱ 7 min · Topic 2 of 9
a >> b means a must finish before b starts. Each such link is an edge in the graph. A list on one side means all of these tasks.
So one line can make a fan-out, where one task feeds many, and then a fan-in, where many feed one. Together they make a diamond:
A diamondworked example
Python
start >> [load_orders, load_customers] >> join
The edges Airflow built
from
to
start
load_orders
start
load_customers
load_orders
join
load_customers
join
join waits for both loads. The two loads can run at the same time.
A list cannot be on both sides of one >>. Python does not know how to join two lists:
Two listsworked example
Python
[a, b] >> [c, d]
Result · 1 row
error
TypeError: unsupported operand type(s) for >>: 'list' and 'list'
Two helpers from airflow.sdk cover this case, and they build different graphs:
Code
Edges
Meaning
chain([a, b], [c, d])
a → c, b → d
Pairs, in order. The lists must have the same length.
cross_downstream([a, b], [c, d])
a → c, a → d, b → c, b → d
Every task on the left feeds every task on the right.
chain([a, b], [c]) fails with a ValueError, because 2 and 1 cannot be paired. chain is most useful for tasks made in a loop: chain(*tasks) puts them in a row.
Watch out
A missing edge raises no error. The task just starts before its input is ready, and may read old data. After any change to the wiring, look at the Graph view.
03 · @task
From classic operators to @task
⏱ 5 min · Topic 3 of 9
In the classic style, you create an operator object for each task. You set the order with >>, and you pull the value from XCom yourself:
The same two tasks, the same edge, and the same value. Airflow pushes and pulls the XCom for you.
Underneath, each @task function still becomes an operator. So the scheduler runs both styles in the same way, and one DAG can mix them.
04 · Edges
Calls become edges
⏱ 5 min · Topic 4 of 9
In TaskFlow, passing one task’s result to another task makes the edge. The order and the data are one statement, so they cannot disagree.
In the classic style they can disagree. You might write load >> build but pull a value from a third task. The graph looks right, and the task reads the wrong value.
When no value moves, use >> as before:
Order without dataworked example
Python
refresh_dashboard() >> send_email()
The email must wait for the dashboard. Nothing is passed between them.
To give a classic operator’s result to a @task, use its .output:
virtualenv and docker help when one task needs a library version that the rest cannot use. But both add start-up time to every run of that task.
07 · Choosing
When a classic operator is better
⏱ 5 min · Topic 7 of 9
Use @task for your own logic. Use a provider operator for work that a provider already does.
A provider operator for a system such as Databricks already handles waiting for the job, errors and logins. If you rewrite it as a @task, all of that becomes your code to maintain.
So most real DAGs mix both styles:
A mixed DAGworked example
Python
count = SQLExecuteQueryOperator(
task_id="count_orders",
conn_id="shop_db",
sql="SELECT count(*) FROM orders WHERE order_date = '{{ ds }}'",
)
@task
def check_count(rows):
print(f"rows: {rows!r}")
if rows[0][0] == 0:
raise ValueError("no orders for this day")
check_count(count.output) >> refresh_dashboard()
Two test runs, on a copy of the shop orders
day
output
run
2026-01-21
rows: [(1,)]
success
2026-01-22
rows: [(0,)], then ValueError
failed
The operator runs the SQL and returns the rows. The @task checks them. Only order 106 is from 2026-01-21.
Choose the style for each task, not for the whole DAG.
08 · Recap
Recap
⏱ 4 min · Topic 8 of 9
The rules for shapes and @task:
a >> b means a finishes before b starts. A list means all of these.
Never put lists on both sides of >>. Use chain() to pair, or cross_downstream() to connect all.
In TaskFlow, passing a value makes the edge. Use >> only when no value moves.
Return small values. A return type of dict splits the keys into separate XComs.
Use @task for your logic, and provider operators for provider work.
Check yourself: you need a and b to both feed c and d. What do you write? (Answer: cross_downstream([a, b], [c, d]).)
09 · Next Chapter
Next Chapter
Every value a @task returns is stored somewhere.
⏱ 3 min · Topic 9 of 9
Next chapter
XComs, Templating & Jinja
Every value a @task returns is stored somewhere.
Next, you learn where XComs live, why they must stay small, and how templates such as {{ ds }} work.