5.1 Airflow basics¶
Concept¶
So far you've run each ShopFlow Spark job by hand: ingest Bronze, build Silver, build Gold (Unit 4). That's fine for learning, but in production nobody sits at a terminal at 6 a.m. to run three commands in the right order. Orchestration is the practice of running data jobs automatically, in the right order, on a schedule, with retries when things fail and visibility into what happened.
Apache Airflow is the most widely used open-source orchestrator. You describe your pipeline as code and it runs it for you. The four problems it solves:
- Dependencies — Silver must not start until Bronze finished. Airflow enforces the order.
- Retries — a flaky Postgres connection shouldn't fail the whole night; Airflow retries a task.
- Scheduling — "run every day at 06:00" is one line, not a cron file you forget.
- Observability — a web UI shows every run, task, log, timing, and success/failure at a glance.
What an orchestrator does¶
Back in Unit 1.1 we said a pipeline is just "steps that turn raw data into useful data." An orchestrator is the thing that runs those steps for you so you don't have to babysit them. Concretely it does four jobs:
- Runs steps in the right order — Bronze, then Silver, then Gold. You declare the order once; the orchestrator never runs Silver before Bronze finishes.
- On a schedule — "every day at 06:00", or "every hour", declared in one line. No human at a terminal, no crontab to hand-maintain.
- With retries — if a step fails on a transient hiccup (a dropped connection), the orchestrator can automatically try it again a few times before giving up and alerting you.
- With backfills — if the pipeline was down for three days, an orchestrator can catch up by running the missed days one by one. (We'll meet this as catchup below and use it for real in 5.3.)
Everything in this lesson is a concrete instance of one of those four jobs. Keep them in mind as the vocabulary below gives each one a name.
The vocabulary¶
Five words carry this whole lesson. Read them once here; you'll see each one appear in real code in the Lab.
- DAG (Directed Acyclic Graph) — your pipeline. It's a set of tasks with a defined order. Directed = the arrows point one way (Bronze → Silver, never back). Acyclic = no loops, so the pipeline always finishes. In Airflow, one Python file defines one (or more) DAGs — the file is the pipeline.
- Task — a single unit of work, one box in the graph (e.g. "run the Bronze Spark job", or here, "echo a message").
- Operator — the type of a task, i.e. what kind of work it does.
BashOperatorruns a shell command,PythonOperatorruns a Python function,SparkSubmitOperatorsubmits a Spark job. You pick an operator, give it parameters, and that becomes a task. - Schedule — how often the DAG runs (
@daily, a cron string, orNonefor "manual only, I'll press the button myself"). - DAG run — one execution of the whole DAG — one pass through all its tasks for one point in time. Inside a run, each task becomes a task instance that succeeds or fails independently, so you can retry just the one that broke instead of rerunning everything.
How the UI shows a run: colored squares
Airflow's Grid view is a table: each DAG run is a column, each task is a row, and every cell is a colored square showing that task instance's state — green = success, red = failed, yellow = running or retrying, grey = not started yet. One glance tells you which run, and which task inside it, went wrong. Click a square to jump to that task's Logs.
flowchart LR
SCHED["Scheduler<br/>(daily)"] -.->|triggers| A
subgraph DAG["DAG — your pipeline as code"]
direction LR
A["Task A<br/>operator"] --> B["Task B<br/>operator"]
end
B -.->|logs and status| UI["Web UI"]
In the lab: your DAGs live in Jupyter's my_files/dags/ folder¶
You don't need to install anything to do the labs. Every DAG you write goes into the my_files/dags/
folder in Jupyter's file browser (your bucket's files/src/dags/); the lab's Airflow loads it
from there within about 30 seconds. One Airflow is shared by the whole class, so one rule
keeps names apart: every dag_id starts with your username and _ — demouser_… for the
default account. (A DAG that breaks the rule shows up under Airflow's import errors with the
name it expects.) In the Airflow UI you see your own DAGs (you can run, edit and delete them)
and the platform's DAGs (read-only) — other members' DAGs and runs stay private to them.
Your my_files/dags/ folder already holds one example: load_sample_orders.py.
Optional: Airflow on your own machine¶
The professional workflow is: write a DAG, test it locally on your own machine, and only then ship it to the shared Airflow (in companies: push it to the Git repo the Airflow git-syncs). This section sets up a Docker-free local Airflow so you never ship a broken DAG — handy, but not needed for the labs (skip ahead to the lab if you like).
Match the stack's version — Airflow 3.3.2
This stack runs Airflow 3, whose DAGs import from airflow.sdk import DAG. That module does
not exist in Airflow 2, so a 2.x local install (e.g. 2.10.5) can't even parse these DAGs.
Always install the same version as the deployment — here, 3.3.2.
You just need Python 3.10+ (python3 --version). Continue with the install below.
Airflow is not supported on native Windows — run it inside WSL 2 (Ubuntu). Open your WSL shell and follow the macOS/Linux steps there.
1. Install the matching version¶
In a clean virtualenv, pinned with Airflow's official constraints file (so providers resolve to compatible versions):
python -m venv .airflow && source .airflow/bin/activate
AIRFLOW_VERSION=3.3.2
PY=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
pip install "apache-airflow==${AIRFLOW_VERSION}" apache-airflow-providers-standard \
--constraint "https://raw.githubusercontent.com/apache/airflow/constraints-${AIRFLOW_VERSION}/constraints-${PY}.txt"
airflow version # → 3.3.2
python -c "import airflow.sdk; print('sdk OK')"
2. Point it at a local home + your DAGs¶
These are environment variables — set them in every new shell (they don't persist; a new shell
without AIRFLOW_HOME silently uses a different, empty database):
export AIRFLOW_HOME=~/airflow-local # your own metadata DB + logs
export AIRFLOW__CORE__LOAD_EXAMPLES=False # skip Airflow's bundled example DAGs (they error without extra deps)
airflow db migrate # one-time: build the local SQLite DB (no server)
export AIRFLOW__CORE__DAGS_FOLDER="$PWD/tmp" # the folder holding the DAG(s) you're working on
airflow dags list # your DAGs, no examples
Use a scratch folder while developing
Point DAGS_FOLDER at a throwaway folder (e.g. tmp/) for the DAG you're writing — that keeps
your work-in-progress away from the shared Airflow until it passes. Once it's green, ship it.
3. Test the DAG — three levels¶
# 1) parse-check only — does it import? (nothing runs)
python tmp/my_dag.py
# 2) run ONE task — LIVE output on your console (reads the file directly, always fresh)
airflow tasks test <dag_id> <task_id> 2024-01-01
# 3) run the WHOLE DAG (respects task order)
airflow dags reserialize # FIRST: parse files -> serialize them into the DB
airflow dags test <dag_id> 2024-01-01 # quiet on success
echo $? # 0 = the run succeeded
<dag_id> is the name inside the file (DAG("my_first_dag", …)), not the filename. Re-run
airflow dags reserialize after each edit before airflow dags test. The <date> must be on or
after the DAG's start_date — test with a date before start_date and Airflow runs no tasks
yet still reports the run "successful" (a common surprise — pick any date between start_date and today).
Why you might see no output
airflow tasks teststreams the task's output (yourecho,print, etc.) to the console — as long as logging is at the default INFO. If you earlier ranexport AIRFLOW__LOGGING__LOGGING_LEVEL=WARNING, it mutes that output —unsetit to see results.airflow dags testis quiet by design: it runs a real DAG run and writes task output to log files, not the console. Success =echo $?is0; inspect it withairflow dags list-runs --dag-id <dag_id>or read~/airflow-local/logs/….
Local = authoring & testing; the stack = running the real jobs
These commands execute the operators. Pure-Python / Bash tasks run offline, but tasks that
reach services (Spark, or postgres:5432 / storage:9000 / trino:8080) need those
services reachable — that's the running stack's job. So validate the DAG's shape locally, run
the heavy pipeline on the stack. These same checks are what you put in CI to gate a DAG
before it's merged and git-synced to a remote cluster.
Common local-Airflow errors & fixes (the ones everyone hits)
| Symptom | Cause | Fix |
|---|---|---|
ModuleNotFoundError: airflow.sdk / DAG won't parse |
local Airflow is v2 | install apache-airflow==3.3.2 |
Dag '<id>' could not be found in DagBag read from database |
dags test reads the DB; DAG not serialized |
run airflow dags reserialize first |
Import tracebacks under example_dags/ (kubernetes / pandas / s3) |
Airflow's bundled examples need extra deps | export AIRFLOW__CORE__LOAD_EXAMPLES=False |
No data found / no such table from dags list |
new/empty or unmigrated DB — usually AIRFLOW_HOME changed between shells |
set AIRFLOW_HOME consistently, then airflow db migrate |
| Ran, but no output on the console | dags test logs to files, or logging is muted |
use airflow tasks test; unset AIRFLOW__LOGGING__LOGGING_LEVEL; check echo $? |
dags test says success but no tasks ran |
the test date is before the DAG's start_date |
use a date ≥ start_date (and ≤ today) |
WARNING - cannot record queued_duration … |
harmless metric note on a one-off test run | ignore |
4. Ship it¶
Once the DAG passes locally, deploy it:
- In the lab: save the file in Jupyter's
my_files/dags/folder — Airflow loads it within ~30 s. - In a company: commit + push to the Git repo the shared Airflow git-syncs — never edit files on the server directly.
Lab¶
1. Open the Airflow UI¶
ShopFlow's Airflow is already running on your stack (see the architecture).
- Local: http://localhost:8001 (sign in with your lab account)
- On k8s: http://airflow.de.lan
Take the tour:
- DAGs list — every pipeline, its schedule, and last run status. The toggle on the left enables/disables (pauses) scheduling.
- Click a DAG → Graph shows tasks and dependencies; Grid shows every run as a column and every task as a row (green = success, red = failed, yellow = running/retry).
- Click any task square → Logs to see exactly what happened.
- The ▶ Trigger button runs a DAG on demand.
2. Write your first DAG¶
Open Jupyter (http://localhost:8008), and in the file browser open the my_files/dags/ folder →
right-click → New File → name it hello_shopflow.py:
This one file is a complete pipeline. It's short on purpose — two trivial tasks that just print text — so you can see the shape of every DAG without any real logic in the way. Read the whole thing first, then we'll walk it line by line.
from airflow.sdk import DAG # Airflow 3.x
from airflow.providers.standard.operators.bash import BashOperator
import pendulum
with DAG(
dag_id="demouser_hello_shopflow", # own account? use your username instead of demouser
description="First tiny DAG — two tasks in order",
schedule=None, # manual only for now
start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
catchup=False,
tags=["unit5", "demo"],
) as dag:
say_hello = BashOperator(
task_id="say_hello",
bash_command="echo 'ShopFlow pipeline starting…'",
)
show_run = BashOperator(
task_id="show_run",
bash_command="echo 'Run id is {{ run_id }}'",
)
# define the dependency: say_hello must finish before show_run
say_hello >> show_run
Read it step by step:
from airflow.sdk import DAG— imports theDAGclass. In Airflow 3.x this lives inairflow.sdk(older tutorials importfrom airflow import DAG— that's the 2.x style; use thesdkone here).from airflow.providers.standard.operators.bash import BashOperator— imports the operator we'll use.BashOperatoris a task that runs a shell command. Operators ship in provider packages; the Bash one lives in thestandardprovider.import pendulum— a date/time library Airflow uses. We only need it to build a proper timezone-awarestart_datebelow.with DAG(...) as dag:— this defines the DAG. Thewith … as dag:block is a Python context manager: every task you create inside the indented block automatically belongs to this DAG. The arguments configure the pipeline:dag_id="demouser_hello_shopflow"— the DAG's unique name. This is exactly what you'll see in the DAGs list in the UI, so make it descriptive. It starts with your username +_because one Airflow serves the whole class.description=...— a one-line summary shown next to the DAG in the UI.schedule=None— how often to run automatically.Nonemeans manual only — it never runs on its own; you press ▶ Trigger. (In 5.3 you'll change this to a real daily schedule.)start_date=pendulum.datetime(2026, 1, 1, tz="UTC")— the date the DAG becomes "eligible" to run. Every DAG needs one, even a manual one. It's the earliest logical date Airflow will consider — think of it as the pipeline's birthday, not "run now."catchup=False— when you do have a schedule,catchupcontrols backfilling: if thestart_dateis in the past, should Airflow run every missed interval to catch up?Falsesays "no, don't run history, just go forward from now." Leaving itTrueon a DAG with an oldstart_dateis the classic beginner surprise — it fires off dozens of runs at once. We keep itFalsehere.tags=["unit5", "demo"]— labels for filtering the DAGs list. Purely cosmetic.
say_hello = BashOperator(task_id="say_hello", bash_command="echo …")— creates the first task.task_idis the task's name (what you see as a box in the Graph and a row in the Grid);bash_commandis the shell command it runs. Because this is written inside thewith DAGblock, the task is automatically attached to the DAG.show_run = BashOperator(...)— the second task, same pattern. Its command prints{{ run_id }}(explained just below).say_hello >> show_run— this is the whole point:>>defines the dependency (the order). Read it as "say_hellothenshow_run" — Airflow will not startshow_rununtilsay_hellohas succeeded. The arrow you'll see between the two boxes in the Graph view is this line.
Nothing here 'runs' when you save the file
Saving hello_shopflow.py doesn't execute any echo. This file is a definition — it builds
the graph (which tasks exist, in what order). The commands only run later, inside a DAG run,
when the scheduler or your ▶ Trigger click actually executes each task. Defining the pipeline
and running it are two separate moments.
{{ run_id }} is a template — Airflow fills it in at run time. Templating is how tasks become
run-aware.
{{ run_id }} vs {{ ds }} — the business date
You'll often want the run's business date, written {{ ds }} (YYYY-MM-DD). But that only
exists for scheduled runs — it comes from the run's logical date. A manual trigger in
Airflow 3 has no logical date, so {{ ds }} would be undefined here; that's why this first DAG
uses {{ run_id }}. You'll use {{ ds }} for the real daily pipeline in 5.3.
3. Run it¶
You wrote the definition; now trigger an actual DAG run and watch the task instances turn green. Airflow rescans the dags folder every ~30s, so give it a moment after saving to appear. In the UI:
- Find demouser_hello_shopflow in the DAGs list (type
demouserin the search box) and toggle it on (unpause). - Click ▶ Trigger.
- Open Grid → you should see two green squares. Click
show_run→ Logs and confirm it printed the run id.
Everything here — authoring, triggering, watching, reading logs — happens in the browser (the Airflow UI, and JupyterLab for the file). You never open a shell inside a container.
Why tasks should be idempotent
Because an orchestrator retries failed tasks and can backfill past days, the same task may run more than once for the same date. A task is idempotent when running it twice gives the same result as running it once — e.g. "overwrite today's Silver partition" rather than "append rows" (which would double them). Idempotent tasks are what make retries and backfills safe. Our echo tasks are trivially idempotent; you'll design the real Spark jobs this way in 5.3.
4. Pass data between tasks (XCom)¶
Tasks often need a value from an earlier task — a row count, a file name, a date. Each task may run on a different machine, so they can't share Python variables. Airflow passes small values through XCom (cross-communication): a task pushes a value, Airflow stores it in its database, and a later task pulls it.
The easiest way is the TaskFlow style: write tasks as plain Python functions. Create
my_files/dags/xcom_demo.py:
from airflow.sdk import dag, task
from airflow.providers.standard.operators.bash import BashOperator
import pendulum
@dag(
dag_id="demouser_xcom_demo", # own account? use your username instead of demouser
schedule=None,
start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
catchup=False,
tags=["unit5"],
)
def xcom_demo():
@task
def extract():
amounts = [120.50, 45.00, 1250.00, 80.00] # pretend we read today's orders
return {"rows": len(amounts), "total": sum(amounts)}
@task
def report(stats: dict):
print(f"{stats['rows']} orders, total {stats['total']:.2f}")
stats = extract()
report(stats)
bash_report = BashOperator(
task_id="bash_report",
bash_command="echo 'rows from XCom: {{ ti.xcom_pull(task_ids=\"extract\")[\"rows\"] }}'",
)
stats >> bash_report
xcom_demo()
Read it step by step:
@dag(...)abovedef xcom_demo():— the TaskFlow way to define a DAG: the same arguments aswith DAG(...), written as a decorator (a@…line that adds behaviour to the function below it). The last line,xcom_demo(), calls the function once so Airflow registers the DAG — don't forget it.@taskabovedef extract():— turns a Python function into a task; itstask_idis the function name (extract).return {...}— whatever a@taskfunction returns is pushed to XCom automatically. Here a small dict:{"rows": 4, "total": 1495.5}.stats = extract()— in the DAG body this doesn't run the function; it wires the task and gives you a handle to its future result.report(stats)— passing that handle as an argument does two things:reportruns afterextract(no>>needed), and at run time Airflow pulls the value from XCom and hands it in asstats.{{ ti.xcom_pull(task_ids="extract")["rows"] }}— the same value in a template, for classic operators likeBashOperator:tiis the running task instance,xcom_pull(task_ids="extract")fetches whatextractreturned, and["rows"]picks one field.stats >> bash_reportmakes sureextracthas finished first.
Trigger it. The logs show:
In the Grid, click the extract square → XCom tab to see the stored value.
If the value is in XCom anyway, why pass it as an argument? Why not pull it inside the task?
You can pull it yourself inside the task:
from airflow.sdk import get_current_context
@task
def report():
ti = get_current_context()["ti"]
stats = ti.xcom_pull(task_ids="extract") # fetch it yourself
print(f"{stats['rows']} orders, total {stats['total']:.2f}")
extract() >> report() # …but now YOU must set the order
get_current_context()["ti"]— the running task instance (more in step 6);ti.xcom_pull(task_ids="extract")fetches whatextractreturned.
That works — but passing the value as an argument is the better default, for three reasons:
1. The order comes for free. report(stats) tells Airflow that report needs extract's
result, so extract always runs first. Pull inside the task and forget the >>, and
Airflow sees two unrelated tasks and starts them at the same time. We tried it:
report ran before extract had stored anything, got None, and crashed.
2. The value doesn't exist yet when the DAG file is read. Airflow reads the file long
before anything runs, only to learn the shape of the graph. At that moment extract hasn't
run — so stats = extract() can't be the dict. It's a placeholder (an XComArg) meaning
"whatever extract returns, later". Passing it to report(stats) tells Airflow: when you
run report, fill this in with the real value. Each task runs later as a separate
process, maybe on another machine, so ordinary Python variables can't carry a value from one
task to the next — XCom carries it, and the argument says where to deliver it.
3. Clearer and easy to test. def report(stats) shows at a glance what the task needs, and
you can test it as a plain function, without Airflow — .function is the original Python
function behind the task:
report(stats) |
ti.xcom_pull(...) inside the task |
|
|---|---|---|
extract runs before report |
automatic | only if you add >> |
| What the task needs | visible in its arguments | hidden in the code |
| Test without Airflow | easy | needs Airflow |
| Use for | the normal case | values from a task that isn't directly before this one, or from a classic operator like BashOperator |
XCom is for small values — never for data
XCom lives in Airflow's own database. Pass small things: a count, a date, a table name,
a file path. Never a DataFrame or thousands of rows. For real data, the first task
writes a table (e.g. my.bronze.orders) and returns its name; the next task reads
the table.
5. Conditional flow (branching)¶
Sometimes the next step depends on the data: new orders arrived → process them; nothing new →
skip. A branch task decides at run time which task(s) run next; the others are skipped.
Create my_files/dags/branch_demo.py:
from airflow.sdk import dag, task
import pendulum
@dag(
dag_id="demouser_branch_demo",
schedule=None,
start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
catchup=False,
tags=["unit5"],
)
def branch_demo():
@task
def count_new_orders():
return 0 # pretend: no new orders today (try 25 later)
@task.branch
def any_new(n: int):
if n > 0:
return "process_orders"
return "skip_processing"
@task
def process_orders():
print("processing the new orders")
@task
def skip_processing():
print("nothing new today")
@task(trigger_rule="none_failed_min_one_success")
def finish():
print("pipeline finished")
decision = any_new(count_new_orders())
decision >> [process_orders(), skip_processing()] >> finish()
branch_demo()
flowchart LR
C[count_new_orders] --> B{"any_new<br/>(branch)"}
B -- "n > 0" --> P[process_orders]
B -- "n = 0" --> S[skip_processing]
P --> F[finish]
S --> F
Read it step by step:
count_new_orders()returns0→ pushed to XCom (step 4).@task.branch— a special task that returns thetask_idof the path to follow.any_new(count_new_orders())feeds it the count;0→ it returns"skip_processing".decision >> [process_orders(), skip_processing()]— both tasks come after the branch (a list in>>means "all of these"). At run time only the one the branch named runs; the other is marked skipped (pink in the Grid).>> finish()— both paths join again infinish.trigger_rule="none_failed_min_one_success"— whenfinishmay run. By default a task runs only when all tasks before it succeeded (all_success). One of them is always skipped here, so with the defaultfinishwould be skipped too — we tried it. This rule says: run if nothing failed and at least one task before me succeeded.
Trigger it. The task states:
count_new_orders success
any_new success → Following branch {'skip_processing'}
process_orders skipped
skip_processing success → nothing new today
finish success → pipeline finished
Change return 0 to return 25, save, wait ~30 s and trigger again — now process_orders runs
and skip_processing is skipped.
Trigger rules you'll meet
trigger_rule= |
The task runs when the tasks before it… |
|---|---|
all_success (default) |
all succeeded |
none_failed_min_one_success |
none failed, at least one succeeded — the usual rule after a branch |
all_done |
all finished, whatever the result — e.g. a clean-up task |
one_failed |
at least one failed — e.g. send an alert |
Stop the rest of the pipeline: @task.short_circuit
A simpler kind of condition: @task.short_circuit returns True (carry on) or False
(skip everything after it). Handy for "nothing new today → stop here" when there's no
other path to take.
6. Other important concepts: retries, parameters, one task per item¶
Three things almost every real DAG uses. Create my_files/dags/concepts_demo.py:
from airflow.sdk import dag, task, Param, get_current_context
import pendulum
@dag(
dag_id="demouser_concepts_demo",
schedule=None,
start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
catchup=False,
default_args={"retries": 2, "retry_delay": pendulum.duration(seconds=10)},
params={"country": Param("GB", type="string", enum=["GB", "US", "DE", "IN"])},
tags=["unit5"],
)
def concepts_demo():
@task
def flaky_download():
ti = get_current_context()["ti"]
print(f"attempt {ti.try_number}")
if ti.try_number < 2:
raise ConnectionError("simulated network hiccup")
print("downloaded")
@task(execution_timeout=pendulum.duration(minutes=5))
def show_country():
params = get_current_context()["params"]
print(f"building the report for {params['country']}")
@task
def load(table: str):
print(f"loading {table}")
flaky_download() >> show_country() >> load.expand(table=["customers", "orders", "products"])
concepts_demo()
Retries — survive a hiccup
default_args={...}— settings applied to every task in the DAG."retries": 2— if a task fails, try it again up to 2 more times;"retry_delay": pendulum.duration(seconds=10)— wait 10 seconds before each retry.get_current_context()["ti"]— inside a task,get_current_context()gives the run's details;["ti"]is the task instance, andti.try_numberis the attempt (1, 2, …).-
raise ConnectionError(...)— fail on purpose on attempt 1, to see a retry. In the Grid the square turns yellow (up for retry), then green. The log has both attempts: -
execution_timeout=pendulum.duration(minutes=5)— fail the task if it runs longer than 5 minutes, instead of hanging forever. Set it on anything that talks to another system.
Parameters — choose values when you trigger
params={"country": Param("GB", type="string", enum=[...])}— a run parameter with a default (GB), a type, and the allowed values (enum).- When you click ▶ Trigger, Airflow shows a form with a country drop-down. Pick
US→ the task reads it withget_current_context()["params"]and logsbuilding the report for US.
One task per item — expand (dynamic task mapping)
load.expand(table=[...])— run theloadtask once per item in the list: three mapped tasks,[0],[1],[2], running in parallel. The logs sayloading customers,loading orders,loading products.- The list can also come from an earlier task (via XCom) — e.g. "one load task per file that arrived today", however many there are.
More concepts, and where you'll meet them
| Concept | What it is | Where |
|---|---|---|
| Schedule & backfill | cron schedules, {{ ds }}, running missed days |
5.3 |
| Sensor | a task that waits for something (a file, a table) | 11.2 |
| Task groups | fold related tasks into one box in the Graph (@task_group) |
Airflow docs |
| Assets | start a DAG when another DAG updates a table, instead of on a clock | Airflow docs |
| Callbacks | run a function on failure — e.g. send a Slack/e-mail alert (on_failure_callback) |
Airflow docs |
Clean up
Delete xcom_demo.py, branch_demo.py and concepts_demo.py from my_files/dags/ when you're done —
Airflow stops loading them within about a minute.
Challenge¶
Add a third task count_tasks that runs after show_run and prints how many tasks the DAG has.
Wire the order say_hello >> show_run >> count_tasks. You're doing two things: making a third
BashOperator, and extending the dependency chain so the new task runs last.
Solution
count_tasks = BashOperator(
task_id="count_tasks",
bash_command="echo 'This DAG has {{ dag.task_ids | length }} tasks'",
)
say_hello >> show_run >> count_tasks
count_tasks = BashOperator(...)— a third task, same pattern as before. Itsbash_commandis a template:{{ dag.task_ids | length }}is filled in at run time with the number of tasks in this DAG (3) — templates can read the DAG itself, not just dates.say_hello >> show_run >> count_tasks— chaining>>reads left to right as "say_hello, thenshow_run, thencount_tasks." This one line replaces the earlier two-task version and wires all three in order.
Chaining with >> scales to any number of tasks — in the Graph view you'll now see three boxes
connected left to right, and count_tasks' log says This DAG has 3 tasks.
🎯 The same orchestration on Azure, Databricks, Snowflake & Fabric
What you just did: described a pipeline as a DAG of tasks wired with >>, then triggered
and inspected it in the Airflow UI.
- Azure Data Factory — a DAG is a Pipeline; tasks are activities; your
>>is a success arrow;schedule=is a Schedule trigger; Grid/Graph is ADF Monitor. - Azure Databricks — a Workflow/Job: multiple tasks with dependencies, schedules, retries, and alerts built in.
- Snowflake — chain Tasks with
AFTERpredecessors to form a task graph (DAG). - Microsoft Fabric — Data Factory in Fabric pipelines: the same activity-and-dependency model.
The concepts — DAG, task, dependency, schedule, retry — are identical everywhere, and Airflow itself runs managed on all of them (ADF Managed Airflow, Amazon MWAA, Google Cloud Composer), so this exact DAG ports almost unchanged.
Key terms, at a glance¶
| Term | Plain meaning |
|---|---|
| Orchestration | Run jobs automatically, in order, on a schedule, with retries & backfills |
| DAG | Your pipeline as code — tasks with a defined order, no cycles |
| Task / task instance | A unit of work / one run of it inside a DAG run |
| Operator | The type of task — what work it does (BashOperator, SparkSubmitOperator, …) |
BashOperator |
An operator whose task runs a shell command (bash_command=…) |
schedule |
@daily / cron / None — how often the DAG runs |
start_date |
The earliest logical date the DAG is eligible to run from |
catchup |
Whether to backfill every missed interval since start_date (usually False) |
Dependency (>>) |
"then" — defines task order; a >> b runs a before b |
| DAG run | One execution of the whole DAG — one pass through all its tasks |
| Idempotent | Re-running the same task/run gives the same result (safe to retry & backfill) |
Template ({{ … }}) |
Value filled in at run time (run_id, ds, …) |
@dag / @task |
TaskFlow: a Python function becomes the DAG / a task |
| XCom | Small values passed from one task to the next (a @task's return value) |
@task.branch |
Returns the task_id to run next; the other paths are skipped |
| Trigger rule | When a task may run, based on the tasks before it (all_success, none_failed_min_one_success, …) |
Retries / retry_delay |
Try a failed task again, after a pause |
execution_timeout |
Fail a task that runs too long |
| Params | Values you choose when you trigger a run (Param) |
.expand() |
Dynamic task mapping — one task per item in a list |
You can now…¶
- Explain what an orchestrator does — order, schedule, retries, backfills — and the four problems Airflow solves
- Name the core Airflow objects: DAG, task, operator (
BashOperator), schedule, DAG run - Define a DAG with
dag_id,schedule,start_date, andcatchup, and wire task order with>> - Write, trigger, and inspect a multi-task DAG in the Airflow UI, reading task states in the Grid
- Use templating (
{{ run_id }}) and know when{{ ds }}applies - Pass small values between tasks with XCom — and know to pass table names, not data
- Build a conditional flow with
@task.branch, and join the paths with a trigger rule - Add retries, a timeout, run parameters and one task per item (
expand)