The TaskFlow Decorators
Concepts covered: DAG definition
A function becomes a task
The TaskFlow style defines tasks as Python functions. @task on a function turns each call of it, inside a DAG, into a task. @dag on a function turns that function into the DAG definition, and the body of the function is where the tasks are created. Here is the nightly report in this style, with values flowing between the tasks.
from datetime import datetime
from airflow.sdk import dag, task
@dag(
dag_id="nightly_bookings_flow",
start_date=datetime(2026, 3, 1),
schedule="@daily",
)
def nightly_bookings_flow():
@task
def get_bookings() -> str:
return "/exports/bookings.csv"
@task
def get_reviews() -> str:
return "/exports/reviews.csv"
@task
def build_report(bookings_path: str, reviews_path: str) -> int:
print(f"joining {bookings_path} with {reviews_path}")
return 18342
@task
def publish_dashboard(row_count: int) -> None:
print(f"report has {row_count} rows")
report_rows = build_report(get_bookings(), get_reviews())
publish_dashboard(report_rows)
nightly_bookings_flow()
The dependency comes from the arguments
No >> appears, and the graph is the same 4 tasks with the same 3 edges. The edges come from the arguments. Passing the result of get_bookings() into build_report tells Airflow that build_report needs a value get_bookings produces, and a task that needs another task's value waits for it: that is the edge. The 2 arguments draw the 2 edges in. Passing report_rows into publish_dashboard draws the third. When get_bookings finishes, Airflow stores its return value, the path string, in its own database; when build_report starts on a worker, Airflow reads that value back and passes it in as bookings_path. The values are serialized as JSON by default, so a value travels if JSON can hold it, which covers strings and numbers as well as lists and dicts. Keep them small, a path, a count, a date, because that database is the record of the run and was sized for records.
| Call in the DAG function | Task created | Edges drawn |
|---|---|---|
| get_bookings() | get_bookings | none yet |
| get_reviews() | get_reviews | none yet |
| build_report(get_bookings(), get_reviews()) | build_report | get_bookings -> build_report, get_reviews -> build_report |
| publish_dashboard(report_rows) | publish_dashboard | build_report -> publish_dashboard |
Reading the file creates the DAG
The last line, nightly_bookings_flow(), is the one that creates the DAG. @dag turns the function into a factory; calling it runs the body, and each @task call inside creates a task object. Delete that line and the file reads without error and defines no DAG, which is the most common reason a TaskFlow DAG is missing from the UI. Inside the body, get_bookings() runs none of the function's code at read time; it creates the task and returns a reference to the value the task will produce. return "/exports/bookings.csv" executes on a worker, once per run. The print in build_report runs on a worker too, so nothing in these 4 functions touches the process that reads the file.
A task's id defaults to the function name, so build_report the function becomes build_report the task. @task(task_id="aggregate") overrides it. Calling the same decorated function twice creates 2 tasks, and Airflow suffixes the second id: get_bookings and get_bookings__1. 2 calls of one function with different arguments is a common way to get 2 tasks from one definition.
Mixing both styles
Operators and decorated tasks live in one DAG. Inside the @dag function, publish = BashOperator(task_id="publish_dashboard", bash_command="curl ...") creates a task the same way it would in a with block, and report_rows >> publish orders it after build_report with no value passed, since this publish reads nothing from it. The reverse also works: report_rows can be fed into an operator's templated field. The Tasks and Data track covers how. Use @task where the work is Python you would write anyway; use an operator where a class already wraps the command you need.
- Reach for @task when
- The work is Python that returns a small value
- The next task needs that value as an argument
- You want the edge to follow the data
- Reach for an operator when
- A class already wraps the command, such as a shell command
- The task produces no value the next one needs
- You are ordering tasks with >> for timing alone
About this lesson
This section is part of the DAG Anatomy: Beginner lesson on DataDriven, the home of data engineering. Lessons cover SQL, Python, Spark, data modeling and pipeline architecture, and each section pairs its explanation with worked examples and practice you run as you go.