3 scripts and a timer
A marketplace team needs a report of yesterday's bookings and reviews on a dashboard by 09:00. The first version is 3 scripts on a server's timer: export_bookings.py at 01:00, export_reviews.py at 01:15, and build_report.py at 02:00. The 02:00 slot is a guess that the 2 exports take under 45 minutes. One night in December the bookings export runs for 70 minutes because a holiday weekend tripled the rows. build_report.py starts at 02:00 on schedule, reads a half-written bookings file, and finishes with no error. The dashboard shows a 40% drop in bookings at 09:00, and the on-call engineer spends the morning finding out why.
| Question that morning | What the timer can tell you |
|---|---|
| Did the bookings export finish before the report started? | Nothing; each timer fires on its own clock |
| Which night did this start? | Nothing; no record of past starts exists |
| Why did the export take 70 minutes? | Only the lines the script printed, if someone saved them to a file |
| Rerun the report for that one night | Edit a date in the script by hand and run it again |
Airflow decides when work may start
The rest of this lesson uses 3 words. A DAG is one described workflow: the tasks and the order between them, defined in one Python file. A task is one unit of work in it, such as running a shell command. A run is one execution of the whole DAG for one scheduled time, so the DAG for the nightly report has one run per night.
The nightly report as a DAG: 2 exports with no edge between them, then a report that waits on both.
Airflow is an orchestrator
- When each run begins
- Which task may start, given what has finished
- The record of every attempt and its log
- The picture of the whole, in one place
- The bookings and reviews themselves
- The query that aggregates them
- The file the export writes
- The dashboard that shows the result
- ▸Directed: every edge points one way, from the task that finishes first to the one that waits for it
- ▸Acyclic: following the arrows never returns to a task you have passed, so every run can finish
- ▸Graph: tasks are the nodes and dependencies are the edges, so the shape can fan out and join back
4 systems, one nightly report
Data moves left to right between the stores; Airflow issues the command at each arrow and keeps none of the rows.
| Step | System that does the work | What Airflow does |
|---|---|---|
| Export yesterday's bookings | App database, via a dump command | Starts the command at 00:00, waits, records the outcome |
| Export yesterday's reviews | App database, the same way | Starts it beside the bookings export, since no edge joins them |
| Load both files | Warehouse, via a load statement | Starts it once both exports show success |
| Aggregate into a daily table | Warehouse, via one SQL statement | Sends the statement, waits, records success or failure |
| Refresh the dashboard | Dashboard tool, via its API | Calls the API and records the response |
Airflow's own record
- Every DAG it has read, with its tasks and edges
- Every run, with its scheduled time and its state
- Every task attempt, with start, end, state and a pointer to its log
- Nothing from the tables your tasks read or write
One orchestrator, many teams
Heavy compute belongs in the warehouse
- Give each task one command to one system
- Keep Airflow's state and your business data in separate stores
- Let the warehouse aggregate; let Airflow wait and record
- Size a task's memory to stay flat as the data grows
- Load a full table into Python inside a task
- Treat Airflow's database as a place to keep business rows
- Compute in the task what the warehouse would do in one statement
- Chain systems by clock time outside the graph
How a task opens a connection to a database or an API, and how it hands a value to the task after it, are covered in the Tasks and Data track.
One file, one graph
A DAG is defined in a Python file placed in Airflow's dags folder. Airflow reads the file and runs its top-level code. Every DAG object that exists when the file ends becomes a DAG Airflow knows about, along with the tasks and edges attached to it. Here is the nightly report as that file, with 4 tasks built from 2 operator types.
3 arguments fix the DAG's identity. dag_id names it, and the name must be unique across the deployment. start_date is the first date the DAG is allowed to run for, and schedule="@daily" asks for one run per day; the Scheduling track covers what both mean in detail. The with DAG(...) as dag: block is the registration: every operator created inside it belongs to this DAG.
Operator in, task out
An operator is a class that describes one kind of work: run a shell command, call a Python function, send a SQL statement. Creating an instance of it inside the with block, with a task_id, creates one task on the DAG. The file above creates 3 tasks from BashOperator and one from PythonOperator. get_bookings and get_reviews are 2 tasks though they share a class, because each instance has its own task_id and its own command. A task_id must be unique within its DAG. Airflow's record identifies the task by it, and so do the UI and the logs, so the Python variable name report appears nowhere outside this file while build_report appears everywhere.
| Operator | What the task does when it runs | Argument that holds the work |
|---|---|---|
| BashOperator | Runs a shell command and fails if the exit code is not 0 | bash_command |
| PythonOperator | Calls a Python function in the task's process | python_callable |
| EmptyOperator | Does nothing; marks a point in the graph | none |
The file describes the work and does not do it
Airflow reads this file to learn the graph, and it reads it again on a timer to pick up changes. Reading means running the top-level Python: the imports, the function definition, the with block that creates 4 operator instances and 3 edges. The string "python export.py bookings" is stored as a task's argument; the export itself runs later, on a machine Airflow controls, when a run reaches that task. The same split holds for build_report: the function is defined at read time and called at run time. A line at the top of the file that queries the warehouse would run every time Airflow reads the file, dozens of times an hour, against a warehouse that has nothing to do with learning the graph.
| Line | When it runs | Where |
|---|---|---|
| from airflow.sdk import DAG | Every time Airflow reads the file | The process that reads DAG files |
| get_bookings = BashOperator(...) | Every read; creates the task object | The process that reads DAG files |
| python export.py bookings | Once per run, when the task starts | A worker machine |
| print(...) inside build_report | Once per run, when build_report starts | A worker machine |
| conn.execute('SELECT ...') at top level | Every read, dozens of times an hour | The process that reads DAG files, by mistake |
Wiring the order
>> reads as 'then'. a >> b makes b downstream of a: b may start only after a finishes with success. << is the same edge written from the other side. A list on either end fans out or fans in: [a, b] >> c draws 2 edges into c, and a >> [b, c] draws 2 edges out of a. Chains compose, so the last line of the file draws 3 edges in one expression.
| Expression | Edges drawn |
|---|---|
| get_bookings >> report | get_bookings -> build_report |
| report << get_reviews | get_reviews -> build_report |
| [get_bookings, get_reviews] >> report | get_bookings -> build_report, get_reviews -> build_report |
| report >> publish | build_report -> publish_dashboard |
| [get_bookings, get_reviews] >> report >> publish | All 3 of the edges above |
The acyclic rule is enforced at read time. Adding publish >> get_bookings to the file would make the arrows loop, so a run could never finish: publish_dashboard waits for build_report, which waits for get_bookings, which now waits for publish_dashboard. Reading the file raises an error that names the cycle, so the file fails to import: the UI lists it among the DAG import errors, with the cycle in the message, and no graph is loaded from it.
Tasks with no path between them run side by side
2 tasks may run at the same time when no path of arrows joins them in either direction. get_bookings and get_reviews have no edge between them in nightly_bookings, so when a run begins at 00:00 both start at once. build_report has 2 edges in, so it starts when the later of the 2 finishes. With the bookings export taking 20 minutes and the reviews export 10, the report starts at 00:20. Had the file said get_bookings >> get_reviews >> report, the exports would run one after the other and the report would start at 00:30. The arrows you draw decide the night's length, so draw only the ones the data requires.
| Task | Fan-in file: starts | Serial file: starts | Finishes (fan-in file) |
|---|---|---|---|
| get_bookings | 00:00 | 00:00 | 00:20 |
| get_reviews | 00:00 | 00:20 | 00:10 |
| build_report | 00:20 | 00:30 | 00:35 |
| publish_dashboard | 00:35 | 00:45 | 00:36 |
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.
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.
- ▸The @dag decorator above the function, where the id and start_date are set along with the schedule
- ▸The call of that function at module level, with no indentation, at the bottom of the file
- ▸Each @task function called inside the body, since a function that is only defined creates no task
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.
| Question | Answer |
|---|---|
| How a task is created | An operator instance inside with DAG |
| How an edge is drawn | a >> b, written by you |
| How a value moves | Not by default |
| Where the task id comes from | The task_id argument |
| Question | Answer |
|---|---|
| How a task is created | A call of a @task function inside @dag |
| How an edge is drawn | Passing one call's result into another |
| How a value moves | Return it; the next task receives it |
| Where the task id comes from | The function name, unless overridden |
- 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
- 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
Graph: the shape of one run
The DAG page in the UI has 2 views of the same DAG, switched by a toggle. The graph draws the tasks as boxes and the edges as arrows, laid out left to right, so nightly_bookings shows get_bookings and get_reviews stacked on the left, build_report to their right with 2 arrows in, and publish_dashboard last. The shape alone answers 2 questions: which tasks can overlap, those with no arrow path between them, and what the longest chain is, which bounds how fast a run can finish. Select a run and each box takes the color of that task's state in that run, so the graph becomes a picture of where one night stands.
Grid: the history of every run
The grid turns time into columns. Each column is one run, oldest on the left and newest on the right; each row is one task; the cell where they meet is colored by the state of that task in that run. Read down a column to see how one night went; read across a row to see how one task has behaved night after night. 3 nights of bookings_report, the 3-task cousin of nightly_bookings that the animation below plays, make 3 columns of 3 cells. The colors are Airflow's state names, and 7 of them cover everything you will see in this lesson.
| Color | State | What it says about the cell |
|---|---|---|
| Green | success | The task finished and its command exited cleanly |
| Red | failed | The task ran and its command exited with an error |
| Orange | upstream_failed | A task upstream of it failed, so it will not run in this run |
| Lime | running | A worker is executing the command now |
| Grey | queued | Its upstream tasks are done; it waits for a worker to pick it up |
| Tan | scheduled | The run has reached it and it is about to be queued |
| No color | none | The run has not reached this task; an upstream task is still unfinished |
from datetime import datetimefrom airflow.sdk import DAG, taskwith DAG(dag_id="bookings_report",start_date=datetime(2026, 3, 1),schedule="@daily",):@task.bashdef get_bookings():return "./bookings.sh"@task.bashdef get_reviews():return "./reviews.sh"@task.bashdef build_report():return "./report.py"report = build_report()get_bookings() >> reportget_reviews() >> report
Play the night above. At 00:00 on March 2 the first column appears and get_bookings and get_reviews turn lime together. build_report stays blank: the cell has no state until both tasks above it finish. At 00:10 get_reviews turns green and build_report still waits, because one of its 2 edges is unmet. At 00:20 the second edge is met, build_report passes through scheduled and queued and runs, and the column is all green at 00:35. On the night of March 3 build_report turns red at 00:35 and the bar over that column turns red with it: a run fails when a task at the end of its graph fails. Switch the dependencies to the serial file and get_reviews stays blank until get_bookings finishes at 00:20, then runs from 00:20 to 00:30; build_report starts at 00:30 instead of 00:20, so every column finishes 10 minutes later, at 00:45.
Reading a grid like a story
| Pattern in the grid | What happened | Where to look first |
|---|---|---|
| 1 red cell, rest of its row green | One night's command failed | That cell's log |
| Red across a row from one column onward | The task broke on that date and stayed broken | The DAG file or the command, as changed around that date |
| Red cell, orange cells below it in the column | An early task failed and nothing downstream ran | The red cell's log |
| 2 rows lime at the same time | Those tasks run side by side; no edge joins them | The graph view, to confirm the shape |
| Column bar lime, bottom cells blank | The run is still in progress | Wait, or watch the running cell |
> A food-delivery company pays its couriers every morning. The payout job needs yesterday's completed orders, cancellations and tips loaded into the warehouse, then one SQL statement that nets them per courier, then a call to the payments provider; finance opens the payout dashboard at 08:00.
Platform created by the community to programmatically author, schedule and monitor workflows.
airflow.apache.org
Apache Airflow, Apache, Airflow, the Airflow logo, and the Apache logo are either registered trademarks or trademarks of The Apache Software Foundation.
Read any DAG file and say what it describes, which tasks run side by side, and what its graph and grid will show.
- Category
- Airflow
- Difficulty
- beginner
- Duration
- 20 minutes
- Challenges
- 5 hands-on challenges
Topics covered: What Airflow Is For, Where Airflow Fits, Tasks in a DAG File, The TaskFlow Decorators, Reading the Graph and Grid
Lesson Sections
- What Airflow Is For
3 scripts and a timer Moving the report to 03:00 buys a month. The timer still knows nothing about the exports: it fires at a clock time whether the work it depends on has finished. Every question the engineer asks that morning comes down to whether one step had finished before the next began, and the timer records none of it. Airflow decides when work may start Airflow takes over the decision the timer could never make: whether a piece of work may start yet. You write the 3 steps as tasks and s
- Where Airflow Fits
4 systems, one nightly report Follow the nightly report end to end to see where the line between what Airflow owns and what its systems own falls. Bookings and reviews are written by the app into a transactional database. The exports land as files in object storage. A warehouse loads the files and runs the SQL that aggregates them into a daily table. A dashboard tool reads that table at 09:00. Each of those 4 systems stores data or computes on it. Every arrow between them is a command an Airflow
- Tasks in a DAG File (concepts: Task dependencies)
One file, one graph Operator in, task out The file describes the work and does not do it Wiring the order Tasks with no path between them run side by side
- The TaskFlow Decorators (concepts: DAG definition)
A function becomes a task The dependency comes from the arguments Reading the file creates the DAG Mixing both styles
- Reading the Graph and Grid (concepts: Task dependencies)
Graph: the shape of one run Grid: the history of every run The bar over a column carries the run's own state: lime while any task is still running, green once every task has finished with success, red once a task at the end of the graph has failed. The bar's height is the run's duration relative to its neighbours, so a night that ran long stands out as a taller bar before you read a single cell. Click a cell and a panel opens with that task's attempt in that run. It shows the attempt's state and